Files
ketle-manandClaude Sonnet 4.6 7f2f42c26a feat: v0.6.8 — GIF出力・PSWボタン改善・サンプルデータ追加
- アニメーションGIF出力機能追加(透過対応、gif_encoder.js 新規)
- GIFカラー量子化・透過インデックスバグ修正
- PSW追加/削除後のレイヤータブ即時更新
- PスイッチタブのスロットボタンUI改善(+MLP黄・+SLP水色・順序変更)
- レイヤータブ「SW追加/削除」→「LSW追加/削除」リネーム
- docs: 0_node.png・1_node.png更新、8_pose_switch.png追加
- user_data: LSW+PSW設定済みモデル・PSWプロジェクト追加
- README(3言語)・DEVLOG更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 12:59:21 +09:00

6734 lines
309 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "/scripts/app.js";
import { t, getLang } from "./i18n.js";
const NODE_TYPE = "PSDFigureCreator";
// ================================================
// ワークフロー保存時に image_data を除外
// ================================================
setTimeout(() => {
if (!app.graph) return;
const _orig = app.graph.serialize.bind(app.graph);
app.graph.serialize = function (...args) {
const data = _orig(...args);
if (data?.nodes) {
for (const n of data.nodes) {
if (n.type !== NODE_TYPE || !n.widgets_values) continue;
// widgets_values 順: psd_filename(0), layer_config(1), output_width(2), output_height(3), image_data(4)
if (n.widgets_values.length > 4) n.widgets_values[4] = "";
}
}
return data;
};
}, 500);
// ================================================
// CSS(一度だけ読み込む)
// ================================================
let _cssLoaded = false;
function ensureCSS() {
if (_cssLoaded) return;
_cssLoaded = true;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = new URL("../css/psd_loader.css", import.meta.url).href;
document.head.appendChild(link);
}
// ================================================
// ウィジェット非表示
// ================================================
function hideWidget(node, name) {
const w = node.widgets?.find(w => w.name === name);
if (!w) return;
if (w.element) {
w.element.style.display = "none";
w.element.style.height = "0";
w.element.style.overflow = "hidden";
}
w.draw = () => {};
w.computeSize = () => [0, -4];
}
function findWidget(node, name) {
return node.widgets?.find(w => w.name === name);
}
// ================================================
// ファイル選択 input(共有)
// ================================================
let _fileInput = null;
function getFileInput() {
if (!_fileInput) {
_fileInput = document.createElement("input");
_fileInput.type = "file";
_fileInput.accept = ".psd";
_fileInput.style.display = "none";
document.body.appendChild(_fileInput);
}
return _fileInput;
}
// ================================================
// 全レイヤー画像をロード(モーダルプレビュー用)
// ================================================
async function loadLayerImages(filename, layers) {
const imageMap = {};
async function fetchLayer(node) {
if (node.kind !== "group") {
const url = `/psd_loader/layer_image?filename=${encodeURIComponent(filename)}&id=${encodeURIComponent(node.id)}`;
try {
const res = await fetch(url);
if (res.ok) {
const blob = await res.blob();
const objUrl = URL.createObjectURL(blob);
const img = await new Promise(resolve => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => { URL.revokeObjectURL(objUrl); resolve(null); };
i.src = objUrl;
});
if (img) {
imageMap[node.id] = {
img, objUrl,
left: node.bbox.left,
top: node.bbox.top,
width: node.bbox.right - node.bbox.left,
height: node.bbox.bottom - node.bbox.top,
};
}
}
} catch (_) {}
} else if (node.bbox) {
// グループ: 画像なしの擬似エントリ(リグポイント配置用にbbox情報だけ持つ)
imageMap[node.id] = {
img: null, objUrl: null, isGroup: true,
left: node.bbox.left,
top: node.bbox.top,
width: node.bbox.right - node.bbox.left,
height: node.bbox.bottom - node.bbox.top,
};
}
if (node.children) {
for (const child of node.children) await fetchLayer(child);
}
}
for (const layer of layers) await fetchLayer(layer);
return imageMap;
}
// ================================================
// SWグループ展開ヘルパー(PSDフォルダグループ対応)
// ================================================
function getPsdGroupLeaves(groupId, layerTree) {
function find(nodes) {
for (const n of nodes) {
if (n.id === groupId && n.kind === 'group' && n.children) {
const leaves = [];
function collect(ns) {
for (const c of ns) {
if (c.kind !== 'group') leaves.push(c.id);
else if (c.children) collect(c.children);
}
}
collect(n.children);
return leaves;
}
if (n.children) { const r = find(n.children); if (r) return r; }
}
return null;
}
return find(layerTree) || [];
}
function expandSwGroupEntries(groups, layerTree, customGroups = []) {
const cgMap = {};
for (const cg of customGroups) cgMap[cg.id] = cg;
const result = [];
for (let i = 0; i < groups.length; i++) {
const entry = groups[i];
if (typeof entry === 'string') {
result.push({ id: entry, entryIdx: i });
} else if (entry?.type === 'psd_group') {
const leaves = getPsdGroupLeaves(entry.id, layerTree);
if (entry.mode === 'composite') {
result.push({ id: entry.id, entryIdx: i, mode: 'composite', memberIds: leaves });
} else {
for (const id of leaves) result.push({ id, entryIdx: i });
}
} else if (entry?.type === 'custom_group') {
const cg = cgMap[entry.id];
const ids = cg?.layer_ids || [];
if (entry.mode === 'composite') {
result.push({ id: entry.id, entryIdx: i, mode: 'composite', memberIds: ids });
} else {
for (const id of ids) result.push({ id, entryIdx: i });
}
}
}
return result;
}
function countSwSlots(groups, layerTree, customGroups = []) {
return expandSwGroupEntries(groups, layerTree, customGroups).length;
}
// ================================================
// レイヤー描画(カメラ変換なし、任意の ctx に描く)
// ================================================
// クリッピングスタック合成用オフスクリーンcanvas。
// _drawPreview はドラッグ中 mousemove ごとに走るため、毎回の生成を避けてキャッシュする
let _clipTmpCanvas = null;
function renderLayersToCtx(ctxArg, layers, imageMap, config) {
let ctx = ctxArg; // クリッピングスタック描画時にオフスクリーンへ一時スワップ可能
const vis = config?.visibility || {};
const cgList = config?.custom_groups || [];
const rigging = config?.rigging || {};
const pose = config?.pose || {};
const layerParentMap = config?.layer_parent || {};
const cgMap = {};
for (const cg of cgList) cgMap[cg.id] = cg;
const cgHidden = new Set();
function markHidden(cg) {
for (const lid of cg.layer_ids) {
cgHidden.add(lid);
if (cgMap[lid]) markHidden(cgMap[lid]);
}
}
for (const cg of cgList) { if (cg.visible === false) markHidden(cg); }
// SWポイントによるグループ表示切り替え(通常のvisibilityを上書き)
const SW_STEP = Math.PI / 6; // 30度
for (const swLayer of (config?.sw_layers || [])) {
for (const swInfo of (swLayer.points || [])) {
if (!swInfo.groups?.length) continue;
const flat = expandSwGroupEntries(swInfo.groups, layers, cgList);
const n = flat.length;
if (n === 0) continue;
const angle = swInfo.angle ?? 0;
const range = SW_STEP * n;
const normAngle = ((angle % range) + range) % range;
const activeSlot = Math.min(Math.floor(normAngle / SW_STEP), n - 1);
for (let i = 0; i < n; i++) {
const slot = flat[i];
const ids = slot.mode === 'composite' ? slot.memberIds : [slot.id];
if (i === activeSlot) {
for (const id of ids) {
cgHidden.delete(id);
if (cgMap[id]) { const unmark = g => { for (const lid of g.layer_ids) { cgHidden.delete(lid); if (cgMap[lid]) unmark(cgMap[lid]); } }; unmark(cgMap[id]); }
}
} else {
for (const id of ids) {
cgHidden.add(id);
if (cgMap[id]) markHidden(cgMap[id]);
}
}
}
}
}
function applyRigTransform(entry, rig, p) {
const pivot = rig.r ?? rig.mr;
if (pivot) {
const px = entry.left + pivot.x;
const py = entry.top + pivot.y;
if (p.angle) {
ctx.translate(px, py);
ctx.rotate(p.angle);
ctx.translate(-px, -py);
}
if (p.flipX || p.flipY) {
ctx.translate(px, py);
ctx.scale(p.flipX ? -1 : 1, p.flipY ? -1 : 1);
ctx.translate(-px, -py);
}
}
if (p.tx || p.ty) ctx.translate(p.tx ?? 0, p.ty ?? 0);
}
// 祖先の累積トランスフォームを取得(描画順は変えず変換のみ伝播)
function getAncestorChain(layerId) {
const chain = [];
const visited = new Set([layerId]);
let cur = layerParentMap[layerId];
while (cur && !visited.has(cur)) {
visited.add(cur);
const rig = rigging[cur], p = pose[cur], e = imageMap[cur];
if (rig && p && e && (p.angle || p.tx || p.ty || p.flipX || p.flipY)) chain.unshift({ entry: e, rig, p });
cur = layerParentMap[cur];
}
return chain;
}
// 全PSDノードをIDで引けるマップ
const nodeMap = {};
const buildNodeMap = ns => { for (const n of ns) { nodeMap[n.id] = n; if (n.children) buildNodeMap(n.children); } };
buildNodeMap(layers);
// いずれかのCGのlayer_idsに属するPSDレイヤーID(CG経由でのみ描画する)
const cgMemberIds = new Set();
for (const cg of cgList) for (const lid of cg.layer_ids) if (!cgMap[lid]) cgMemberIds.add(lid);
function drawLeaf(n) {
const entry = imageMap[n.id];
if (!entry?.img) return;
const chain = getAncestorChain(n.id);
const rig = rigging[n.id], p = pose[n.id];
const hasTf = rig && p && (p.angle || p.tx || p.ty || p.flipX || p.flipY);
if (chain.length > 0 || hasTf) {
ctx.save();
for (const { entry: e, rig: r, p: pp } of chain) applyRigTransform(e, r, pp);
if (hasTf) applyRigTransform(entry, rig, p);
ctx.drawImage(entry.img, entry.left, entry.top, entry.width, entry.height);
ctx.restore();
} else {
ctx.drawImage(entry.img, entry.left, entry.top, entry.width, entry.height);
}
}
// skipCgMembers=true: PSDツリー走査時にCGメンバーをスキップ(二重描画防止)
function renderOneNode(n, skipCgMembers) {
if (cgHidden.has(n.id)) return;
if (skipCgMembers && cgMemberIds.has(n.id)) return;
const stateVis = vis[n.id];
if (!(stateVis !== undefined ? stateVis : n.visible)) return;
if (n.kind === "group" && n.children) {
const rig = rigging[n.id], p = pose[n.id], entry = imageMap[n.id];
if (rig && p && entry && (p.angle || p.tx || p.ty || p.flipX || p.flipY)) {
ctx.save(); applyRigTransform(entry, rig, p);
renderChildren(n.children, skipCgMembers);
ctx.restore();
} else {
renderChildren(n.children, skipCgMembers);
}
} else {
drawLeaf(n);
}
}
// グループの子を描画。clipping=true が続く場合はスタックとして一括処理する
function renderChildren(children, skipCgMembers) {
let i = 0;
while (i < children.length) {
const base = children[i];
const clips = [];
let j = i + 1;
while (j < children.length && children[j].clipping) {
clips.push(children[j]);
j++;
}
if (clips.length > 0) {
renderClippingStack(base, clips, skipCgMembers);
} else {
renderOneNode(base, skipCgMembers);
}
i = j;
}
}
// ベースレイヤー + クリッピングレイヤー群をオフスクリーンで合成して描画する。
// source-atop によりクリッピングレイヤーをベースの不透明領域のみに表示する。
// 各レイヤーに設定された R/MR リグは drawLeaf 経由で正常に適用される。
function renderClippingStack(base, clipLayers, skipCgMembers) {
if (cgHidden.has(base.id)) return;
if (skipCgMembers && cgMemberIds.has(base.id)) return;
const baseVis = vis[base.id];
if (!(baseVis !== undefined ? baseVis : base.visible)) return;
const mainCanvas = ctxArg.canvas;
if (!_clipTmpCanvas) _clipTmpCanvas = document.createElement('canvas');
const tmpCanvas = _clipTmpCanvas;
if (tmpCanvas.width !== mainCanvas.width) tmpCanvas.width = mainCanvas.width;
if (tmpCanvas.height !== mainCanvas.height) tmpCanvas.height = mainCanvas.height;
const tmpCtx = tmpCanvas.getContext('2d');
tmpCtx.setTransform(1, 0, 0, 1, 0, 0);
tmpCtx.clearRect(0, 0, tmpCanvas.width, tmpCanvas.height);
// 現在のトランスフォーム(カメラ変換含む)をオフスクリーンにコピー
const t = ctx.getTransform();
tmpCtx.setTransform(t.a, t.b, t.c, t.d, t.e, t.f);
// ctx をオフスクリーンに一時スワップ — drawLeaf/applyRigTransform が自動的に tmpCtx を使う
const savedCtx = ctx;
ctx = tmpCtx;
drawLeaf(base);
tmpCtx.globalCompositeOperation = 'source-atop';
for (const clip of clipLayers) {
if (cgHidden.has(clip.id)) continue;
if (skipCgMembers && cgMemberIds.has(clip.id)) continue;
const clipVis = vis[clip.id];
if (!(clipVis !== undefined ? clipVis : clip.visible)) continue;
drawLeaf(clip);
}
tmpCtx.globalCompositeOperation = 'source-over';
ctx = savedCtx;
// トランスフォームをリセットして 1:1 でメインcanvas に転送
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(tmpCanvas, 0, 0);
ctx.restore();
}
// ID列(下層→上層)を描画する。連続する通常ノードは renderChildren に
// まとめて渡し、ルート直下やCG内でもクリッピングスタックを成立させる
function renderIdList(ids, skipCgMembers, skipCgMemberEntries) {
let run = [];
const flushRun = () => { if (run.length) { renderChildren(run, skipCgMembers); run = []; } };
for (const id of ids) {
if (skipCgMemberEntries && cgMemberIds.has(id)) continue; // CGメンバーはCG経由で描画済み
if (cgMap[id]) { flushRun(); renderCg(cgMap[id]); }
else { const node = nodeMap[id]; if (node) run.push(node); }
}
flushRun();
}
// CG内のlayer_idsを逆順(末尾=下層)で描画
function renderCg(cg) {
if (cgHidden.has(cg.id) || cg.visible === false) return;
renderIdList([...cg.layer_ids].reverse(), false, false);
}
const cgOrder = config?.cg_order;
if (cgOrder && cgOrder.length > 0) {
// cgOrder[0]=最上位 → 逆順で描画(下層から上層へ)
renderIdList([...cgOrder].reverse(), true, true);
} else {
// フォールバック: PSD元順序(psd-toolsは下層→上層)
renderChildren(layers, false);
}
}
// ================================================
// layerParent 祖先チェーンと点変換ヘルパー
// ================================================
function buildAncestorChain(layerId, layerParentMap, rigging, pose, imageMap) {
const chain = [];
const visited = new Set([layerId]);
let cur = layerParentMap?.[layerId];
while (cur && !visited.has(cur)) {
visited.add(cur);
const rig = rigging?.[cur], p = pose?.[cur], e = imageMap?.[cur];
if (rig && p && e && (p.angle || p.tx || p.ty || p.flipX || p.flipY)) chain.unshift({ entry: e, rig, p });
cur = layerParentMap?.[cur];
}
return chain;
}
// ローカル(ワールド)座標 → 祖先トランスフォーム適用後の視覚座標
function applyChainToPoint(x, y, chain) {
for (const { entry: e, rig: r, p } of chain) {
const pivot = r.r ?? r.mr;
if (pivot) {
const px = e.left + pivot.x, py = e.top + pivot.y;
if (p.angle) {
const cos = Math.cos(p.angle), sin = Math.sin(p.angle);
const dx = x - px, dy = y - py;
x = px + dx * cos - dy * sin;
y = py + dx * sin + dy * cos;
}
if (p.flipX) x = 2 * px - x;
if (p.flipY) y = 2 * py - y;
}
x += p.tx ?? 0;
y += p.ty ?? 0;
}
return { x, y };
}
// 視覚座標 → ローカル(ワールド)座標(逆変換)
function inverseChainToPoint(x, y, chain) {
for (const { entry: e, rig: r, p } of [...chain].reverse()) {
x -= p.tx ?? 0;
y -= p.ty ?? 0;
const pivot = r.r ?? r.mr;
if (pivot) {
const px = e.left + pivot.x, py = e.top + pivot.y;
// flip は自己逆(2回適用で元に戻る)
if (p.flipX) x = 2 * px - x;
if (p.flipY) y = 2 * py - y;
if (p.angle) {
const cos = Math.cos(-p.angle), sin = Math.sin(-p.angle);
const dx = x - px, dy = y - py;
x = px + dx * cos - dy * sin;
y = py + dx * sin + dy * cos;
}
}
}
return { x, y };
}
// ================================================
// リグポイント1点の描画ヘルパー
// ================================================
function _drawRigPoint(ctx, x, y, r, fill, stroke = "#fff") {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.5;
ctx.stroke();
}
// ================================================
// リグラベル描画ヘルパー(PSD座標系・カメラ変換内で呼ぶ)
// ================================================
function _drawRigLabel(ctx, x, y, label, isSelected, pointSize = 1.0) {
ctx.save();
const fontSize = Math.max(8, Math.round(11 * pointSize));
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const tw = ctx.measureText(label).width;
const pad = 3;
const ly = y - Math.round(10 * pointSize);
ctx.fillStyle = "rgba(0,0,0,0.75)";
ctx.fillRect(x - tw / 2 - pad, ly - fontSize - 2, tw + pad * 2, fontSize + 3);
ctx.fillStyle = isSelected ? "#ffdd44" : "rgba(255,255,255,0.9)";
ctx.fillText(label, x, ly);
ctx.restore();
}
// ================================================
// リグオーバーレイ描画
// mode: 'setup' | 'pose'
// setupPointType: 'r' | 'mr' (setupモード時のみ使用)
// selectedLayerId: 現在選択中のレイヤーID
// showLabels: ポイント上にレイヤー名を表示するか
// renamed: { layerId: name } の名前上書きマップ
// ================================================
function drawRigOverlay(ctx, layers, imageMap, rigging, pose, mode, selectedLayerId, setupPointType, showLabels = false, renamed = {}, layerParentMap = {}, customGroups = [], pointSize = 1.0, swLayers = [], selectedSwPointInfo = null, pswLayers = [], selectedPswPointInfo = null) {
if ((!rigging || Object.keys(rigging).length === 0) && swLayers.length === 0) return;
const PR = Math.round(7 * pointSize);
const SR = Math.round(12 * pointSize);
const PSR = Math.round(11 * pointSize);
function flatLayers(nodes, arr = []) {
for (const n of nodes) { arr.push(n); if (n.children) flatLayers(n.children, arr); }
return arr;
}
const allLayers = flatLayers(layers);
for (const cg of customGroups) {
if (rigging[cg.id] && !allLayers.some(n => n.id === cg.id)) {
allLayers.push({ id: cg.id, name: cg.name });
}
}
for (const n of allLayers) {
const rig = rigging[n.id];
if (!rig?.r && !rig?.mr) continue;
const entry = imageMap?.[n.id];
if (!entry) continue;
const p = pose?.[n.id] || { angle: 0, tx: 0, ty: 0 };
const tx = p.tx ?? 0, ty = p.ty ?? 0;
const isSelected = n.id === selectedLayerId;
// 祖先トランスフォームをctxに適用(子リグポイントを正しい視覚位置に表示)
const chain = buildAncestorChain(n.id, layerParentMap, rigging, pose, imageMap);
if (chain.length > 0) {
ctx.save();
for (const { entry: e, rig: r, p: pp } of chain) {
if (pp.angle) {
const piv = r.r ?? r.mr;
if (piv) {
const px = e.left + piv.x, py = e.top + piv.y;
ctx.translate(px, py); ctx.rotate(pp.angle); ctx.translate(-px, -py);
}
}
if (pp.tx || pp.ty) ctx.translate(pp.tx ?? 0, pp.ty ?? 0);
}
}
if (mode === 'setup' && isSelected) {
// ---- セットアップモード(選択中): 素の座標で詳細表示 ----
// Rポイント(青)
if (rig.r) {
const rX = entry.left + rig.r.x;
const rY = entry.top + rig.r.y;
_drawRigPoint(ctx, rX, rY, PR, "#4499ff");
if (setupPointType === 'r') {
ctx.save();
ctx.strokeStyle = "rgba(68,153,255,0.9)";
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(rX, rY, SR, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
if (showLabels) {
_drawRigLabel(ctx, rX, rY, renamed?.[n.id] ?? n.name ?? String(n.id), true, pointSize);
}
}
// MRポイント(赤+オレンジ)
if (rig.mr) {
const sX = entry.left + rig.mr.x;
const sY = entry.top + rig.mr.y;
if (rig.mr_radius > 0) {
const mrA = rig.mr_angle ?? 0;
const oX = sX + rig.mr_radius * Math.cos(mrA);
const oY = sY + rig.mr_radius * Math.sin(mrA);
// 可動範囲円(オレンジ破線)
ctx.save();
ctx.setLineDash([5, 5]);
ctx.strokeStyle = "rgba(255,136,0,0.5)";
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(sX, sY, rig.mr_radius, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
// MRポイント→オレンジポイント: 赤の点線で結ぶ
ctx.save();
ctx.setLineDash([4, 3]);
ctx.strokeStyle = "rgba(220,50,50,0.85)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sX, sY); ctx.lineTo(oX, oY); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
_drawRigPoint(ctx, oX, oY, PR, "#ff8800");
// 半径ラベル
ctx.save();
ctx.fillStyle = "rgba(255,160,0,0.85)";
ctx.font = `${Math.max(8, Math.round(10 * pointSize))}px sans-serif`;
ctx.textBaseline = "bottom";
ctx.fillText(`R: ${rig.mr_radius}`, oX + 9, oY);
ctx.restore();
}
_drawRigPoint(ctx, sX, sY, PR, "#ff3333");
if (setupPointType === 'mr') {
ctx.save();
ctx.strokeStyle = "rgba(255,51,51,0.9)";
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(sX, sY, SR, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
if (showLabels && !rig.r) {
// Rポイントがない場合はMRポイントの上にラベル
_drawRigLabel(ctx, sX, sY, renamed?.[n.id] ?? n.name ?? String(n.id), true, pointSize);
}
}
} else {
// ---- ポーズモード・非選択セットアップ: ポーズ適用後の座標 ----
// Rポイント(青)
if (rig.r) {
const rX = entry.left + rig.r.x + tx;
const rY = entry.top + rig.r.y + ty;
_drawRigPoint(ctx, rX, rY, PR, "#4499ff");
if (isSelected) {
ctx.save();
ctx.strokeStyle = "rgba(255,230,0,0.9)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(rX, rY, PSR, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
if (showLabels) {
_drawRigLabel(ctx, rX, rY, renamed?.[n.id] ?? n.name ?? String(n.id), isSelected, pointSize);
}
}
// MRポイント(赤固定 + オレンジ移動ハンドル)
if (rig.mr) {
const sX = entry.left + rig.mr.x;
const sY = entry.top + rig.mr.y;
const oX = sX + tx;
const oY = sY + ty;
if (rig.mr_radius > 0) {
// 可動範囲円(オレンジ破線)
ctx.save();
ctx.setLineDash([5, 5]);
ctx.strokeStyle = "rgba(255,136,0,0.4)";
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(sX, sY, rig.mr_radius, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
// MR→オレンジ: 赤の点線
ctx.save();
ctx.setLineDash([4, 3]);
ctx.strokeStyle = "rgba(220,50,50,0.85)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sX, sY); ctx.lineTo(oX, oY); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
_drawRigPoint(ctx, oX, oY, PR, "#ff8800");
}
_drawRigPoint(ctx, sX, sY, PR, "#ff3333");
if (isSelected && !rig.r) {
ctx.save();
ctx.strokeStyle = "rgba(255,230,0,0.9)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(sX, sY, PSR, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
if (showLabels && !rig.r) {
_drawRigLabel(ctx, sX, sY, renamed?.[n.id] ?? n.name ?? String(n.id), isSelected, pointSize);
}
}
}
// flip インジケーター(↔/↕)
if (p.flipX || p.flipY) {
const pivot = rig.r ?? rig.mr;
if (pivot) {
const isSetupSelected = (mode === 'setup' && isSelected);
const indX = entry.left + pivot.x + (isSetupSelected ? 0 : tx);
const indY = entry.top + pivot.y + (isSetupSelected ? 0 : ty);
const label = (p.flipX && p.flipY) ? "↔↕" : p.flipX ? "↔" : "↕";
ctx.save();
ctx.font = `bold ${Math.max(10, Math.round(13 * pointSize))}px sans-serif`;
ctx.fillStyle = "rgba(255,220,0,0.95)";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(label, indX, indY + Math.round(10 * pointSize));
ctx.restore();
}
}
// 祖先トランスフォームを元に戻す
if (chain.length > 0) ctx.restore();
}
// ---- SWポイント描画 ----
const SW_STEP = Math.PI / 6; // 30度
for (const swLayer of swLayers) {
for (const swInfo of (swLayer.points || [])) {
const oX = swInfo.x ?? 0;
const oY = swInfo.y ?? 0;
const radius = swInfo.radius ?? 80;
const angle = swInfo.angle ?? 0;
const hX = oX + radius * Math.cos(angle);
const hY = oY + radius * Math.sin(angle);
const isSelected = selectedSwPointInfo?.swLayerId === swLayer.id
&& selectedSwPointInfo?.pointId === swInfo.id;
// グループインデックス計算(PSDフォルダ/カスタムグループ展開後のスロット数を使用)
const flat = expandSwGroupEntries(swInfo.groups || [], layers, customGroups);
const n = flat.length;
let activeIdx = 0;
if (n > 0) {
const range = SW_STEP * n;
const normAngle = ((angle % range) + range) % range;
activeIdx = Math.min(Math.floor(normAngle / SW_STEP), n - 1);
}
// セットアップモード選択中: 可動範囲弧を表示
if (mode === 'setup' && isSelected && n > 0) {
ctx.save();
ctx.strokeStyle = "rgba(80,200,80,0.4)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.arc(oX, oY, radius, 0, SW_STEP * n);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// 各ステップの目盛り線
for (let i = 0; i <= n; i++) {
const a = SW_STEP * i;
ctx.save();
ctx.strokeStyle = i === activeIdx || i === (activeIdx + 1) ? "rgba(80,220,80,0.8)" : "rgba(80,180,80,0.4)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(oX + (radius - 8) * Math.cos(a), oY + (radius - 8) * Math.sin(a));
ctx.lineTo(oX + (radius + 2) * Math.cos(a), oY + (radius + 2) * Math.sin(a));
ctx.stroke();
ctx.restore();
}
}
// 緑の線(原点→ハンドル)
ctx.save();
ctx.strokeStyle = "#44cc44";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(oX, oY); ctx.lineTo(hX, hY); ctx.stroke();
ctx.restore();
// 選択リング
if (isSelected) {
ctx.save();
ctx.strokeStyle = "rgba(255,230,0,0.9)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(oX, oY, Math.round(11 * pointSize), 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
// 緑の原点
_drawRigPoint(ctx, oX, oY, Math.round(7 * pointSize), "#22aa22", "#fff");
// 水色のハンドル
_drawRigPoint(ctx, hX, hY, Math.round(7 * pointSize), "#44cccc", "#fff");
// グループ番号を水色ハンドルの中に表示
if (n > 0) {
ctx.save();
ctx.fillStyle = "#fff";
ctx.font = `bold ${Math.max(7, Math.round(9 * pointSize))}px sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(String(activeIdx + 1), hX, hY);
ctx.restore();
}
// SW名ラベル
if (swInfo.name) {
_drawRigLabel(ctx, oX, oY, swInfo.name, isSelected, pointSize);
}
}
}
// ---- PSWポイント描画 ----
const PSW_STEP = Math.PI / 6; // 30度
for (const pswLayer of pswLayers) {
for (const pswInfo of (pswLayer.points || [])) {
const oX = pswInfo.x ?? 0;
const oY = pswInfo.y ?? 0;
const radius = pswInfo.radius ?? 80;
const angle = pswInfo.angle ?? 0;
const hX = oX + radius * Math.cos(angle);
const hY = oY + radius * Math.sin(angle);
const isSelected = selectedPswPointInfo?.pswLayerId === pswLayer.id
&& selectedPswPointInfo?.pointId === pswInfo.id;
const slots = pswInfo.slots || [{ degree: 0, pose: null }];
const n = slots.length;
// セットアップモード選択中: 可動範囲弧を表示
if (mode === 'setup' && isSelected && n > 1) {
ctx.save();
ctx.strokeStyle = "rgba(200,180,255,0.4)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.arc(oX, oY, radius, 0, PSW_STEP * (n - 1));
ctx.stroke();
ctx.setLineDash([]);
for (let i = 0; i <= n - 1; i++) {
const a = PSW_STEP * i;
ctx.strokeStyle = "rgba(200,160,255,0.5)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(oX + (radius - 8) * Math.cos(a), oY + (radius - 8) * Math.sin(a));
ctx.lineTo(oX + (radius + 2) * Math.cos(a), oY + (radius + 2) * Math.sin(a));
ctx.stroke();
}
ctx.restore();
}
// 白い線(原点→ハンドル)
ctx.save();
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(oX, oY); ctx.lineTo(hX, hY); ctx.stroke();
ctx.restore();
// 選択リング
if (isSelected) {
ctx.save();
ctx.strokeStyle = "rgba(255,230,0,0.9)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(oX, oY, Math.round(11 * pointSize), 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
// 白い原点
_drawRigPoint(ctx, oX, oY, Math.round(7 * pointSize), "#ffffff", "#aaaaaa");
// 紫のハンドル
_drawRigPoint(ctx, hX, hY, Math.round(7 * pointSize), "#cc88ff", "#fff");
// スロット番号をハンドル中に表示
if (n > 0) {
const range = PSW_STEP * n;
const normAngle = ((angle % range) + range) % range;
const activeIdx = Math.min(Math.floor(normAngle / PSW_STEP), n - 1);
ctx.save();
ctx.fillStyle = "#fff";
ctx.font = `bold ${Math.max(7, Math.round(9 * pointSize))}px sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(String(activeIdx), hX, hY);
ctx.restore();
}
// PSW名ラベル
if (pswInfo.name) {
_drawRigLabel(ctx, oX, oY, pswInfo.name, isSelected, pointSize);
}
}
}
}
// ================================================
// リグポイントのヒットテスト(PSD座標)
// 返り値: null | { layerId, type: 'r'|'mr'|'orange' }
// ================================================
function hitTestRig(wx, wy, layers, imageMap, rigging, pose, mode, zoom, layerParentMap = {}, customGroups = [], pointSize = 1.0, swLayers = [], pswLayers = []) {
const HIT_R = 14 / zoom * pointSize;
function flatLayers(nodes, arr = []) {
for (const n of nodes) { arr.push(n); if (n.children) flatLayers(n.children, arr); }
return arr;
}
const allLayers = flatLayers(layers);
for (const cg of customGroups) {
if (rigging[cg.id] && !allLayers.some(n => n.id === cg.id)) {
allLayers.push({ id: cg.id, name: cg.name });
}
}
let bestDist = Infinity, bestHit = null;
for (const n of [...allLayers].reverse()) {
const rig = rigging?.[n.id];
if (!rig?.r && !rig?.mr) continue;
const entry = imageMap?.[n.id];
if (!entry) continue;
const p = pose?.[n.id] || { tx: 0, ty: 0 };
const tx = p.tx ?? 0, ty = p.ty ?? 0;
// 祖先トランスフォームを適用した視覚座標でヒットテスト
const chain = buildAncestorChain(n.id, layerParentMap, rigging, pose, imageMap);
const toVis = (x, y) => chain.length > 0 ? applyChainToPoint(x, y, chain) : { x, y };
if (mode === 'setup') {
if (rig.r) {
const { x: rX, y: rY } = toVis(entry.left + rig.r.x, entry.top + rig.r.y);
const d1 = Math.hypot(wx - rX, wy - rY);
if (d1 < HIT_R && d1 < bestDist) { bestDist = d1; bestHit = { layerId: n.id, type: 'r' }; }
}
if (rig.mr) {
const { x: sX, y: sY } = toVis(entry.left + rig.mr.x, entry.top + rig.mr.y);
const d2 = Math.hypot(wx - sX, wy - sY);
if (d2 < HIT_R && d2 < bestDist) { bestDist = d2; bestHit = { layerId: n.id, type: 'mr' }; }
if (rig.mr_radius > 0) {
const mrA = rig.mr_angle ?? 0;
const { x: oX, y: oY } = toVis(
entry.left + rig.mr.x + rig.mr_radius * Math.cos(mrA),
entry.top + rig.mr.y + rig.mr_radius * Math.sin(mrA)
);
const d3 = Math.hypot(wx - oX, wy - oY);
if (d3 < HIT_R && d3 < bestDist) { bestDist = d3; bestHit = { layerId: n.id, type: 'orange' }; }
}
}
} else {
// ポージング: R=ポーズ後座標、MR=固定座標・orange=MR+平行移動
if (rig.r) {
const { x: rX, y: rY } = toVis(entry.left + rig.r.x + tx, entry.top + rig.r.y + ty);
const d1 = Math.hypot(wx - rX, wy - rY);
if (d1 < HIT_R && d1 < bestDist) { bestDist = d1; bestHit = { layerId: n.id, type: 'r' }; }
}
if (rig.mr) {
const { x: sX, y: sY } = toVis(entry.left + rig.mr.x, entry.top + rig.mr.y);
if (rig.mr_radius > 0) {
const { x: oX, y: oY } = toVis(entry.left + rig.mr.x + tx, entry.top + rig.mr.y + ty);
const d3 = Math.hypot(wx - oX, wy - oY);
if (d3 < HIT_R && d3 < bestDist) { bestDist = d3; bestHit = { layerId: n.id, type: 'orange' }; }
}
const d2 = Math.hypot(wx - sX, wy - sY);
if (d2 < HIT_R && d2 < bestDist) { bestDist = d2; bestHit = { layerId: n.id, type: 'mr' }; }
}
}
}
// SWポイントのヒットテスト(ハンドル+セットアップ時は原点も)
for (const swLayer of swLayers) {
for (const swInfo of (swLayer.points || [])) {
const oX = swInfo.x ?? 0;
const oY = swInfo.y ?? 0;
const radius = swInfo.radius ?? 80;
const angle = swInfo.angle ?? 0;
const hX = oX + radius * Math.cos(angle);
const hY = oY + radius * Math.sin(angle);
// ハンドル(水色点)は両モードで操作可能
const dh = Math.hypot(wx - hX, wy - hY);
if (dh < HIT_R && dh < bestDist) {
bestDist = dh;
bestHit = { swLayerId: swLayer.id, swPointId: swInfo.id, type: 'sw_handle' };
}
// 原点(緑点)はセットアップモードのみ移動可能
if (mode === 'setup') {
const do_ = Math.hypot(wx - oX, wy - oY);
if (do_ < HIT_R && do_ < bestDist) {
bestDist = do_;
bestHit = { swLayerId: swLayer.id, swPointId: swInfo.id, type: 'sw_origin' };
}
}
}
}
// PSWポイントのヒットテスト(ハンドル+セットアップ時は原点も)
for (const pswLayer of pswLayers) {
for (const pswInfo of (pswLayer.points || [])) {
const oX = pswInfo.x ?? 0;
const oY = pswInfo.y ?? 0;
const radius = pswInfo.radius ?? 80;
const angle = pswInfo.angle ?? 0;
const hX = oX + radius * Math.cos(angle);
const hY = oY + radius * Math.sin(angle);
const dh = Math.hypot(wx - hX, wy - hY);
if (dh < HIT_R && dh < bestDist) {
bestDist = dh;
bestHit = { pswLayerId: pswLayer.id, pswPointId: pswInfo.id, type: 'psw_handle' };
}
if (mode === 'setup') {
const do_ = Math.hypot(wx - oX, wy - oY);
if (do_ < HIT_R && do_ < bestDist) {
bestDist = do_;
bestHit = { pswLayerId: pswLayer.id, pswPointId: pswInfo.id, type: 'psw_origin' };
}
}
}
}
return bestHit;
}
// ================================================
// モーダルプレビュー描画(canvas ごとセットアップ)
// ================================================
function drawPreview(canvas, psdW, psdH, layers, imageMap, config) {
if (!canvas || !psdW || !psdH) return;
canvas.width = psdW;
canvas.height = psdH;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, psdW, psdH);
const gs = Math.max(8, Math.round(psdW / 40));
for (let y = 0; y < psdH; y += gs) {
for (let x = 0; x < psdW; x += gs) {
ctx.fillStyle = ((Math.floor(x / gs) + Math.floor(y / gs)) % 2 === 0) ? "#2a2a2a" : "#3a3a3a";
ctx.fillRect(x, y, gs, gs);
}
}
renderLayersToCtx(ctx, layers, imageMap, config);
}
// ワールド座標 → キャンバス座標変換
function worldToCanvas(wx, wy, cam, W, H) {
return {
x: W / 2 + cam.zoom * (wx + cam.x - W / 2),
y: H / 2 + cam.zoom * (wy + cam.y - H / 2),
};
}
// ================================================
// 出力フレーム計算
// output_width/output_height のアスペクト比に合わせた
// プレビューキャンバス内の切り取り矩形を返す
// ================================================
function computeOutputFrame(node) {
if (!node._nodeCanvas) return null;
const srcW = node._nodeCanvas.width;
const srcH = node._nodeCanvas.height;
const outW = findWidget(node, "output_width")?.value || 512;
const outH = findWidget(node, "output_height")?.value || 512;
const outAspect = outW / outH;
const srcAspect = srcW / srcH;
let fw, fh;
if (outAspect >= srcAspect) {
fw = srcW;
fh = Math.round(srcW / outAspect);
} else {
fh = srcH;
fw = Math.round(srcH * outAspect);
}
const fx = Math.round((srcW - fw) / 2);
const fy = Math.round((srcH - fh) / 2);
return { fx, fy, fw, fh, srcW, srcH, outW, outH };
}
// ================================================
// カスタムグループ用の仮想エントリを含むimageMapを返す
// ================================================
function getEffectiveImageMap(node, config) {
const base = node._layerImages || {};
const psdW = node._psdW || 0;
const psdH = node._psdH || 0;
const cgList = config?.custom_groups || [];
if (!cgList.length) return base;
const map = Object.assign(Object.create(null), base);
for (const cg of cgList) {
if (!(cg.id in map)) {
map[cg.id] = { img: null, objUrl: null, isCgLayer: true, left: 0, top: 0, width: psdW, height: psdH };
}
}
return map;
}
// ================================================
// ノード内プレビューキャンバス描画(カメラ変換あり)
// ================================================
function drawNodeCanvas(node, { skipFrameLabel = false, targetCanvas = null } = {}) {
const canvas = targetCanvas || node._nodeCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// background_image 入力ポートの接続状態を確認
const bgLinked = !!(node.inputs?.find(i => i.name === 'background_image')?.link);
// 背景描画(優先順:ローカル画像 > ローカル色 > 接続インジケーター > チェッカー)
if (node._bgImage) {
const img = node._bgImage;
const imgAsp = img.naturalWidth / img.naturalHeight;
const canAsp = W / H;
let bw, bh, bx, by;
if (imgAsp > canAsp) { bw = W; bh = W / imgAsp; } else { bh = H; bw = H * imgAsp; }
bx = (W - bw) / 2; by = (H - bh) / 2;
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, W, H);
ctx.drawImage(img, bx, by, bw, bh);
} else if (node._bgColorEnabled && node._bgColor) {
ctx.fillStyle = node._bgColor;
ctx.fillRect(0, 0, W, H);
} else if (bgLinked) {
// 接続された背景画像(内容はPython側で合成。キャンバスにはインジケーター表示)
ctx.fillStyle = "#141f14";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "rgba(68,204,68,0.12)";
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.fillStyle = "rgba(68,204,68,0.6)";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(t("externalBgConnected"), W / 2, H / 2);
ctx.fillText(t("queuePromptToApply"), W / 2, H / 2 + 18);
ctx.restore();
} else {
// チェッカー背景(透明時のデフォルト)
const gs = 12;
for (let y = 0; y < H; y += gs) {
for (let x = 0; x < W; x += gs) {
ctx.fillStyle = ((Math.floor(x / gs) + Math.floor(y / gs)) % 2 === 0) ? "#2a2a2a" : "#3a3a3a";
ctx.fillRect(x, y, gs, gs);
}
}
}
const cam = node._camera || { x: 0, y: 0, zoom: 1, roll: 0 };
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.rotate(cam.roll || 0);
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-W / 2 + cam.x, -H / 2 + cam.y);
let _config = {};
if (node._layerImages && node._psdW && node._psdH && node._psdLayers) {
try { _config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
const _imgMap = getEffectiveImageMap(node, _config);
// PSWポーズを適用した有効configでレンダリング
const _effectivePoseNode = computePswEffectivePose(_config.pose || {}, _config.psw_layers || [], null, node._pswEnabled !== false);
const _renderConfigNode = Object.assign({}, _config, { pose: _effectivePoseNode });
renderLayersToCtx(ctx, node._psdLayers, _imgMap, _renderConfigNode);
// リグオーバーレイ(ポージングモード)
if (_config.rigging || _config.sw_layers?.length || _config.psw_layers?.length) {
drawRigOverlay(
ctx, node._psdLayers, _imgMap,
_config.rigging || {}, _effectivePoseNode, 'pose',
node._rigSelectedLayerId ?? null, null,
node._showRigLabels ?? false, _config.renamed || {}, _config.layer_parent || {},
_config.custom_groups || [], node._rigPointSize ?? 1.0,
_config.sw_layers || [], node._selectedSwPointInfo ?? null,
_config.psw_layers || [], node._selectedPswPointInfo ?? null
);
}
} else if (node._compositeImg && node._psdW && node._psdH) {
ctx.drawImage(node._compositeImg, 0, 0, node._psdW, node._psdH);
}
// ---- PSD境界オーバーレイ(参考用・破線)ctx変換内で描画してロール対応 ----
if (node._psdW && node._psdH) {
ctx.save();
ctx.strokeStyle = "rgba(255,255,100,0.5)";
ctx.lineWidth = 1 / cam.zoom;
ctx.setLineDash([4 / cam.zoom, 4 / cam.zoom]);
ctx.strokeRect(0, 0, node._psdW, node._psdH);
ctx.restore();
}
ctx.restore();
// ---- 出力フレームオーバーレイ(output_width × output_height のアスペクト比) ----
const frame = computeOutputFrame(node);
if (frame) {
const { fx, fy, fw, fh } = frame;
// フレーム外を暗幕(フレーム内は出力範囲)
ctx.fillStyle = "rgba(0,0,0,0.55)";
if (fy > 0) ctx.fillRect(0, 0, W, fy);
if (fy + fh < H) ctx.fillRect(0, fy + fh, W, H - (fy + fh));
if (fx > 0) ctx.fillRect(0, fy, fx, fh);
if (fx + fw < W) ctx.fillRect(fx + fw, fy, W - (fx + fw), fh);
// フレーム枠(白い線)
ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.lineWidth = 1.5;
ctx.strokeRect(fx + 0.5, fy + 0.5, fw - 1, fh - 1);
// 角マーカー(L字)
const m = 12;
ctx.strokeStyle = "#fff";
ctx.lineWidth = 2.5;
for (const [cx, cy, dx, dy] of [
[fx, fy, 1, 1],
[fx + fw, fy, -1, 1],
[fx, fy + fh, 1, -1],
[fx + fw, fy + fh,-1, -1],
]) {
ctx.beginPath();
ctx.moveTo(cx + dx * m, cy);
ctx.lineTo(cx, cy);
ctx.lineTo(cx, cy + dy * m);
ctx.stroke();
}
// 出力サイズをフレーム内に表示
if (!skipFrameLabel) {
const label = `${frame.outW} × ${frame.outH}`;
ctx.font = "bold 11px sans-serif";
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(fx + fw - 82, fy + fh - 18, 80, 16);
ctx.fillStyle = "rgba(255,255,255,0.9)";
ctx.fillText(label, fx + fw - 4, fy + fh - 3);
}
}
if (node._previewStatusEl) node._previewStatusEl.textContent = "";
}
// サムネイル用キャプチャ:ラベルオフで描画→取得→状態を復元
function captureThumbFromNode(node, thumbW, thumbH) {
const canvas = node._nodeCanvas;
if (!canvas) return null;
const wasShowing = node._showRigLabels;
node._showRigLabels = false;
drawNodeCanvas(node, { skipFrameLabel: true });
const fr = computeOutputFrame(node);
const w = thumbW || 140;
const h = thumbH !== undefined ? thumbH : (fr ? Math.max(1, Math.round(w * fr.fh / fr.fw)) : 88);
const tc = document.createElement("canvas"); tc.width = w; tc.height = h;
const tx = tc.getContext("2d");
if (fr) tx.drawImage(canvas, fr.fx, fr.fy, fr.fw, fr.fh, 0, 0, w, h);
else tx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, w, h);
const result = tc.toDataURL("image/png");
node._showRigLabels = wasShowing;
drawNodeCanvas(node);
return result;
}
// ================================================
// カメラ初期状態をPSDサイズに合わせてセット
// ================================================
function setDefaultCamera(node) {
if (!node._psdW || !node._psdH || !node._nodeCanvas) return;
const W = node._nodeCanvas.width;
const H = node._nodeCanvas.height;
const zoom = Math.min(W / node._psdW, H / node._psdH) * 0.95;
const cam = {
x: (W - node._psdW) / 2,
y: (H - node._psdH) / 2,
zoom,
roll: 0,
};
node._camera = { ...cam };
node._defaultCamera = { ...cam };
}
function resetNodeCamera(node) {
const dc = node._defaultCamera || { x: 0, y: 0, zoom: 1 };
node._camera = { ...dc };
drawNodeCanvas(node);
}
function resetNodePose(node) {
let config = {};
try { config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
if (!config.rigging) return;
config.pose = {};
for (const id of Object.keys(config.rigging)) {
config.pose[id] = { angle: 0, tx: 0, ty: 0 };
}
const w = findWidget(node, "layer_config");
if (w) w.value = JSON.stringify(config);
drawNodeCanvas(node);
}
// ================================================
// ノードプレビューを更新
// ================================================
function _setNodePreview(node, config) {
const filename = node._psdFilename || findWidget(node, "psd_filename")?.value;
if (!filename || !node._nodeCanvas) return;
if (node._previewStatusEl) node._previewStatusEl.textContent = t("loading");
const params = new URLSearchParams({
filename,
config: JSON.stringify(config),
width: String(node._nodeCanvas.width * 2), // 高解像度で取得
_t: Date.now(),
});
const img = new Image();
img.onload = () => {
node._compositeImg = img;
drawNodeCanvas(node);
};
img.onerror = () => {
if (node._previewStatusEl) node._previewStatusEl.textContent = t("previewError");
};
img.src = `/psd_loader/preview?${params}`;
}
async function refreshNodePreview(node) {
const filename = node._psdFilename || findWidget(node, "psd_filename")?.value;
if (!filename || !node._nodeCanvas) return;
let config = {};
try { config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
if (node._layerImages && node._psdW && node._psdH && node._psdLayers) {
drawNodeCanvas(node);
return;
}
_setNodePreview(node, config);
}
// ================================================
// PSDアップロード処理
// ================================================
async function uploadPSD(file, node) {
if (!file?.name.toLowerCase().endsWith(".psd")) {
alert(t("onlyPsdAllowed"));
return;
}
const form = new FormData();
form.append("file", file);
try {
const res = await fetch("/psd_loader/upload", { method: "POST", body: form });
const data = await res.json();
if (data.error) throw new Error(data.error);
const fw = findWidget(node, "psd_filename");
if (fw) fw.value = data.filename;
node._psdFilename = data.filename;
node._psdLayers = data.layers;
node._psdW = data.width;
node._psdH = data.height;
node._layerImages = null;
node._compositeImg = null;
if (node._psdBtn) node._psdBtn.textContent = `📂 ${data.filename}`;
setDefaultCamera(node);
await refreshNodePreview(node);
} catch (err) {
alert(t("uploadError", err.message));
}
}
// ================================================
// キャプチャ(出力フレーム領域をoutW×outHに変換して出力)
// ================================================
function captureNode(node) {
const imgWidget = findWidget(node, "image_data");
if (!imgWidget || !node._nodeCanvas) return false;
if (!node._psdW || !node._psdH) {
if (node._previewStatusEl) node._previewStatusEl.textContent = t("psdNotSelected");
return false;
}
const frame = computeOutputFrame(node);
if (!frame) return false;
const { fx, fy, fw, fh, srcW, srcH, outW, outH } = frame;
const cam = node._camera;
const tmp = document.createElement("canvas");
tmp.width = outW;
tmp.height = outH;
const tmpCtx = tmp.getContext("2d");
// 背景を先に描画(カメラ変換前)
if (node._bgImage) {
const img = node._bgImage;
const imgAsp = img.naturalWidth / img.naturalHeight;
const outAsp = outW / outH;
let bw, bh, bx, by;
if (imgAsp > outAsp) { bw = outW; bh = outW / imgAsp; } else { bh = outH; bw = outH * imgAsp; }
bx = (outW - bw) / 2; by = (outH - bh) / 2;
tmpCtx.fillStyle = "#000";
tmpCtx.fillRect(0, 0, outW, outH);
tmpCtx.drawImage(img, bx, by, bw, bh);
} else if (node._bgColorEnabled && node._bgColor) {
tmpCtx.fillStyle = node._bgColor;
tmpCtx.fillRect(0, 0, outW, outH);
}
// プレビューのフレーム領域(fw×fh)をoutW×outHにマッピング
// world → preview: px = srcW/2 + zoom*(wx + camX - srcW/2)
// preview → frame: kx = px - fx (fx = (srcW-fw)/2 なので srcW/2 - fx = fw/2)
// frame → output: ox = kx * outW/fw
// Combined: ox = outW/2 + (outW/fw) * zoom * (wx + camX - srcW/2)
const scaleOut = outW / fw; // = outH / fh(出力アスペクト比 = フレームアスペクト比)
tmpCtx.save();
tmpCtx.translate(outW / 2, outH / 2);
tmpCtx.rotate(cam.roll || 0);
tmpCtx.scale(cam.zoom * scaleOut, cam.zoom * scaleOut);
tmpCtx.translate(-srcW / 2 + cam.x, -srcH / 2 + cam.y);
let config = {};
try { config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
if (node._layerImages && node._psdLayers) {
renderLayersToCtx(tmpCtx, node._psdLayers, getEffectiveImageMap(node, config), config);
} else if (node._compositeImg) {
tmpCtx.drawImage(node._compositeImg, 0, 0, node._psdW, node._psdH);
}
tmpCtx.restore();
imgWidget.value = tmp.toDataURL("image/png");
return true;
}
// ================================================
// 出力キャンバスへの共通レンダリング(captureNode・動画エクスポート共用)
// ================================================
function renderToOutputCanvas(node, destCanvas) {
const frame = computeOutputFrame(node);
if (!frame) return false;
const { fw, fh, srcW, srcH, outW, outH } = frame;
const cam = node._camera;
if (destCanvas.width !== outW) destCanvas.width = outW;
if (destCanvas.height !== outH) destCanvas.height = outH;
const ctx = destCanvas.getContext("2d");
ctx.clearRect(0, 0, outW, outH);
if (node._bgImage) {
const img = node._bgImage;
const imgAsp = img.naturalWidth / img.naturalHeight;
const outAsp = outW / outH;
let bw, bh, bx, by;
if (imgAsp > outAsp) { bw = outW; bh = outW / imgAsp; } else { bh = outH; bw = outH * imgAsp; }
bx = (outW - bw) / 2; by = (outH - bh) / 2;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, outW, outH);
ctx.drawImage(img, bx, by, bw, bh);
} else if (node._bgColorEnabled && node._bgColor) {
ctx.fillStyle = node._bgColor;
ctx.fillRect(0, 0, outW, outH);
}
const scaleOut = outW / fw;
ctx.save();
ctx.translate(outW / 2, outH / 2);
ctx.rotate(cam.roll || 0);
ctx.scale(cam.zoom * scaleOut, cam.zoom * scaleOut);
ctx.translate(-srcW / 2 + cam.x, -srcH / 2 + cam.y);
let config = {};
try { config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
if (node._layerImages && node._psdLayers) {
renderLayersToCtx(ctx, node._psdLayers, getEffectiveImageMap(node, config), config);
} else if (node._compositeImg) {
ctx.drawImage(node._compositeImg, 0, 0, node._psdW, node._psdH);
}
ctx.restore();
return true;
}
// ================================================
// キーフレームシステム
// ================================================
function _kfLerp(a, b, t) { return a + (b - a) * t; }
// 角度の最短経路補間(-π〜π の範囲)
function _kfLerpAngle(a, b, t) {
let d = b - a;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
return a + d * t;
}
// 指定フレームでのポーズを補間して返す
function _kfGetInterpolatedState(keyframes, frame) {
if (!keyframes.length) return null;
let before = null, after = null;
for (const kf of keyframes) {
if (kf.frame <= frame) before = kf;
if (kf.frame >= frame && !after) after = kf;
}
if (!before && !after) return null;
if (!before) return after;
if (!after) return before;
if (before.frame === after.frame) return before;
const t = (frame - before.frame) / (after.frame - before.frame);
// pose 補間
const pose = {};
const poseIds = new Set([...Object.keys(before.pose || {}), ...Object.keys(after.pose || {})]);
for (const id of poseIds) {
const a = before.pose?.[id] || { angle: 0, tx: 0, ty: 0 };
const b = after.pose?.[id] || { angle: 0, tx: 0, ty: 0 };
pose[id] = {
angle: _kfLerpAngle(a.angle ?? 0, b.angle ?? 0, t),
tx: _kfLerp(a.tx ?? 0, b.tx ?? 0, t),
ty: _kfLerp(a.ty ?? 0, b.ty ?? 0, t),
flipX: t < 0.5 ? (a.flipX ?? false) : (b.flipX ?? false),
flipY: t < 0.5 ? (a.flipY ?? false) : (b.flipY ?? false),
};
}
// SW角度補間
const sw_angles = {};
const swIds = new Set([...Object.keys(before.sw_angles || {}), ...Object.keys(after.sw_angles || {})]);
for (const id of swIds) {
sw_angles[id] = _kfLerpAngle(before.sw_angles?.[id] ?? 0, after.sw_angles?.[id] ?? 0, t);
}
// PSW角度補間
const psw_angles = {};
const pswIds = new Set([...Object.keys(before.psw_angles || {}), ...Object.keys(after.psw_angles || {})]);
for (const id of pswIds) {
psw_angles[id] = _kfLerpAngle(before.psw_angles?.[id] ?? 0, after.psw_angles?.[id] ?? 0, t);
}
// pswEnabled はステップ(before を使用)
const pswEnabled = before.pswEnabled !== undefined ? before.pswEnabled : true;
// visibility はステップ(before を使用)
const visibility = JSON.parse(JSON.stringify(before.visibility || {}));
// camera 補間:ポーズ KF とは独立してカメラ付き KF だけを探して補間
let camera = null;
{
let camBefore = null, camAfter = null;
for (const kf of keyframes) {
if (kf.camera && kf.frame <= frame) camBefore = kf;
if (kf.camera && kf.frame >= frame && !camAfter) camAfter = kf;
}
if (camBefore || camAfter) {
if (!camBefore) {
camera = { ...camAfter.camera };
} else if (!camAfter) {
camera = { ...camBefore.camera };
} else if (camBefore.frame === camAfter.frame) {
camera = { ...camBefore.camera };
} else {
const tc = (frame - camBefore.frame) / (camAfter.frame - camBefore.frame);
const a = camBefore.camera;
const b = camAfter.camera;
camera = {
zoom: _kfLerp( a.zoom ?? 1, b.zoom ?? 1, tc),
x: _kfLerp( a.x ?? 0, b.x ?? 0, tc),
y: _kfLerp( a.y ?? 0, b.y ?? 0, tc),
roll: _kfLerpAngle( a.roll ?? 0, b.roll ?? 0, tc),
};
}
}
}
return { pose, sw_angles, psw_angles, pswEnabled, visibility, camera };
}
// 指定フレームにシーク(layer_config を更新して再描画)
// silent=true のとき node canvas の描画をスキップ(動画エクスポート時)
function seekToFrame(node, frame, { silent = false } = {}) {
const total = Math.max(1, node._kfTotalFrames || 60);
frame = Math.max(0, Math.min(total - 1, frame));
node._kfCurrentFrame = frame;
if (node._kfCurrentFrameEl) node._kfCurrentFrameEl.value = frame;
const kfs = node._keyframes || [];
if (kfs.length > 0) {
const state = _kfGetInterpolatedState(kfs, frame);
if (state) {
const w = findWidget(node, "layer_config");
if (w) {
let config = {};
try { config = JSON.parse(w.value || "{}"); } catch (_) {}
if (state.pose !== undefined) {
config.pose = JSON.parse(JSON.stringify(state.pose));
}
if (state.sw_angles && Object.keys(state.sw_angles).length > 0) {
for (const swl of (config.sw_layers || [])) {
for (const pt of (swl.points || [])) {
if (pt.id in state.sw_angles) pt.angle = state.sw_angles[pt.id];
}
}
}
if (state.psw_angles && Object.keys(state.psw_angles).length > 0) {
for (const pswl of (config.psw_layers || [])) {
for (const pt of (pswl.points || [])) {
if (pt.id in state.psw_angles) pt.angle = state.psw_angles[pt.id];
}
}
}
if (state.visibility && Object.keys(state.visibility).length > 0) {
config.visibility = config.visibility || {};
Object.assign(config.visibility, state.visibility);
}
w.value = JSON.stringify(config);
}
// PSW トグル状態を復元
if (state.pswEnabled !== undefined) {
node._pswEnabled = state.pswEnabled;
if (node._updatePswToggle) node._updatePswToggle();
}
// カメラ補間値を適用(silent でも WebM エクスポートが node._camera を参照するため常に適用)
if (state.camera && node._camera) {
node._camera.zoom = state.camera.zoom;
node._camera.x = state.camera.x;
node._camera.y = state.camera.y;
node._camera.roll = state.camera.roll;
}
}
}
if (!silent) drawNodeCanvas(node);
updateTimelineCanvas(node);
}
// 現在フレームにキーフレームを追加/上書き
function addKeyframeAtCurrentFrame(node) {
const w = findWidget(node, "layer_config");
if (!w) return;
let config = {};
try { config = JSON.parse(w.value || "{}"); } catch (_) {}
const frame = node._kfCurrentFrame || 0;
const sw_angles = {};
for (const swl of (config.sw_layers || [])) {
for (const pt of (swl.points || [])) {
sw_angles[pt.id] = pt.angle ?? 0;
}
}
const psw_angles = {};
for (const pswl of (config.psw_layers || [])) {
for (const pt of (pswl.points || [])) {
psw_angles[pt.id] = pt.angle ?? 0;
}
}
// 同フレームに既存 KF があればカメラデータを引き継ぐ
const existingKf = (node._keyframes || []).find(k => k.frame === frame);
const kf = {
frame,
pose: JSON.parse(JSON.stringify(config.pose || {})),
sw_angles,
psw_angles,
pswEnabled: node._pswEnabled !== false,
visibility: JSON.parse(JSON.stringify(config.visibility || {})),
};
if (existingKf?.camera) kf.camera = existingKf.camera;
node._keyframes = (node._keyframes || []).filter(k => k.frame !== frame);
node._keyframes.push(kf);
node._keyframes.sort((a, b) => a.frame - b.frame);
// layer_config に永続化
config.keyframes = JSON.parse(JSON.stringify(node._keyframes));
w.value = JSON.stringify(config);
updateTimelineCanvas(node);
}
// 現在フレームのポーズキーフレームを削除(camera フィールドは保持。両方なければエントリ削除)
function deleteKeyframeAtCurrentFrame(node) {
const frame = node._kfCurrentFrame || 0;
node._keyframes = (node._keyframes || []).map(k => {
if (k.frame !== frame) return k;
// ポーズ系フィールドを除去し camera だけ残す
if (k.camera) return { frame: k.frame, camera: k.camera };
return null; // camera もなければエントリ削除
}).filter(Boolean);
const w = findWidget(node, "layer_config");
if (w) {
let config = {};
try { config = JSON.parse(w.value || "{}"); } catch (_) {}
config.keyframes = JSON.parse(JSON.stringify(node._keyframes));
w.value = JSON.stringify(config);
}
updateTimelineCanvas(node);
}
// 現在フレームにカメラをキーフレーム登録(既存 KF があれば camera フィールドを上書き、なければ新規エントリ)
function addCameraKeyframeAtCurrentFrame(node) {
const cam = node._camera;
if (!cam) return;
const frame = node._kfCurrentFrame || 0;
const camSnap = { zoom: cam.zoom ?? 1, x: cam.x ?? 0, y: cam.y ?? 0, roll: cam.roll ?? 0 };
const existing = (node._keyframes || []).find(k => k.frame === frame);
if (existing) {
existing.camera = camSnap;
} else {
node._keyframes = node._keyframes || [];
node._keyframes.push({ frame, camera: camSnap });
node._keyframes.sort((a, b) => a.frame - b.frame);
}
const w = findWidget(node, "layer_config");
if (w) {
let config = {};
try { config = JSON.parse(w.value || "{}"); } catch (_) {}
config.keyframes = JSON.parse(JSON.stringify(node._keyframes));
w.value = JSON.stringify(config);
}
updateTimelineCanvas(node);
}
// 現在フレームのカメラキーフレームを削除(ポーズデータは保持。KF 自体が空になれば削除)
function deleteCameraKeyframeAtCurrentFrame(node) {
const frame = node._kfCurrentFrame || 0;
node._keyframes = (node._keyframes || []).map(k => {
if (k.frame !== frame) return k;
const { camera, ...rest } = k;
return rest;
}).filter(k => k.camera !== undefined || Object.keys(k.pose || {}).length > 0 || Object.keys(k.sw_angles || {}).length > 0 || Object.keys(k.visibility || {}).length > 0);
const w = findWidget(node, "layer_config");
if (w) {
let config = {};
try { config = JSON.parse(w.value || "{}"); } catch (_) {}
config.keyframes = JSON.parse(JSON.stringify(node._keyframes));
w.value = JSON.stringify(config);
}
updateTimelineCanvas(node);
}
// キーフレームを別フレームに移動(ポーズ・カメラ一緒に移動。移動先に既存 KF があれば上書き)
function moveKeyframe(node, fromFrame, toFrame) {
if (fromFrame === toFrame) return;
const movingKf = (node._keyframes || []).find(k => k.frame === fromFrame);
if (!movingKf) return;
node._keyframes = node._keyframes.filter(k => k.frame !== fromFrame && k.frame !== toFrame);
movingKf.frame = toFrame;
node._keyframes.push(movingKf);
node._keyframes.sort((a, b) => a.frame - b.frame);
const w = findWidget(node, "layer_config");
if (w) {
let cfg = {}; try { cfg = JSON.parse(w.value || "{}"); } catch (_) {}
cfg.keyframes = JSON.parse(JSON.stringify(node._keyframes));
w.value = JSON.stringify(cfg);
}
updateTimelineCanvas(node);
}
// タイムラインキャンバスを再描画
function updateTimelineCanvas(node) {
const canvas = node._kfTimelineCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
const total = Math.max(2, node._kfTotalFrames || 60);
const cur = node._kfCurrentFrame || 0;
const kfs = node._keyframes || [];
ctx.clearRect(0, 0, W, H);
// 背景
ctx.fillStyle = "#1a1a2e";
ctx.fillRect(0, 0, W, H);
// トラックライン
const trackY = Math.round(H / 2);
ctx.fillStyle = "#313244";
ctx.fillRect(8, trackY - 2, W - 16, 4);
// キーフレームダイヤモンド(ポーズのみ:黄、カメラのみ:紫、両方:緑)
for (const kf of kfs) {
const x = 8 + (kf.frame / (total - 1)) * (W - 16);
const isAt = (kf.frame === cur);
const hasPose = kf.pose !== undefined;
const hasCam = kf.camera !== undefined;
const color = (hasPose && hasCam) ? "#44ee88"
: hasCam ? "#cc66ff"
: "#ffdd44";
ctx.save();
ctx.translate(Math.round(x), trackY);
ctx.rotate(Math.PI / 4);
const half = isAt ? 6 : 5;
ctx.fillStyle = color;
if (isAt) {
ctx.shadowColor = color;
ctx.shadowBlur = 6;
}
ctx.fillRect(-half, -half, half * 2, half * 2);
ctx.restore();
}
// プレイヘッド
const px = 8 + (cur / (total - 1)) * (W - 16);
ctx.strokeStyle = "rgba(255,80,80,0.9)";
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(px, 2); ctx.lineTo(px, H - 2); ctx.stroke();
// プレイヘッド上三角
ctx.fillStyle = "rgba(255,80,80,0.9)";
ctx.beginPath();
ctx.moveTo(px - 4, 0);
ctx.lineTo(px + 4, 0);
ctx.lineTo(px, 5);
ctx.fill();
// フレーム番号
ctx.fillStyle = "#888";
ctx.font = "9px sans-serif";
ctx.textBaseline = "bottom";
ctx.textAlign = "left";
ctx.fillText(String(cur), 10, H - 1);
ctx.textAlign = "right";
ctx.fillText(String(total - 1), W - 10, H - 1);
}
// タイムラインキャンバスのドラッグ操作
function setupTimelineInteraction(canvas, node) {
let dragging = false;
let movingKfFrame = null; // キー移動モード: 現在ドラッグ中の KF のフレーム番号
function frameFromClientX(clientX) {
const rect = canvas.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - rect.left - 8) / (rect.width - 16)));
const total = Math.max(2, node._kfTotalFrames || 60);
return Math.round(ratio * (total - 1));
}
// クリック位置から 12px 以内で最も近い KF を返す
function nearestKf(clientX) {
const rect = canvas.getBoundingClientRect();
const clickX = clientX - rect.left;
const W = rect.width;
const total = Math.max(2, node._kfTotalFrames || 60);
let best = null, bestDist = 12;
for (const kf of (node._keyframes || [])) {
const kfX = 8 + (kf.frame / (total - 1)) * (W - 16);
const dist = Math.abs(clickX - kfX);
if (dist < bestDist) { bestDist = dist; best = kf; }
}
return best;
}
canvas.addEventListener("mousedown", e => {
dragging = true;
if (node._kfMoveKeyMode) {
const kf = nearestKf(e.clientX);
if (kf) {
movingKfFrame = kf.frame;
seekToFrame(node, kf.frame);
} else {
movingKfFrame = null;
}
} else {
seekToFrame(node, frameFromClientX(e.clientX));
}
});
window.addEventListener("mousemove", e => {
if (!dragging) return;
if (node._kfMoveKeyMode && movingKfFrame !== null) {
const newFrame = frameFromClientX(e.clientX);
if (newFrame !== movingKfFrame) {
moveKeyframe(node, movingKfFrame, newFrame);
movingKfFrame = newFrame;
seekToFrame(node, newFrame, { silent: true });
}
} else if (!node._kfMoveKeyMode) {
seekToFrame(node, frameFromClientX(e.clientX));
}
});
window.addEventListener("mouseup", () => {
if (node._kfMoveKeyMode && movingKfFrame !== null) {
seekToFrame(node, movingKfFrame);
}
dragging = false;
movingKfFrame = null;
});
// カーソル変更: キー移動モード時はダイヤモンド付近で grab
canvas.addEventListener("mousemove", e => {
if (!node._kfMoveKeyMode) { canvas.style.cursor = "pointer"; return; }
canvas.style.cursor = (dragging || nearestKf(e.clientX)) ? "grab" : "default";
});
canvas.addEventListener("mouseleave", () => { canvas.style.cursor = "pointer"; });
}
// 再生開始
function startPlayback(node) {
if (node._kfPlaying) return;
node._kfPlaying = true;
if (node._kfPlayBtn) {
node._kfPlayBtn.textContent = "⏸";
node._kfPlayBtn.style.background = "#4a2a1a";
}
const fps = Math.max(1, node._kfFps || 24);
const interval = 1000 / fps;
let lastTime = performance.now();
function tick() {
if (!node._kfPlaying) return;
const now = performance.now();
if (now - lastTime >= interval) {
lastTime += interval;
const total = Math.max(2, node._kfTotalFrames || 60);
seekToFrame(node, (node._kfCurrentFrame + 1) % total);
}
node._kfPlayRaf = requestAnimationFrame(tick);
}
node._kfPlayRaf = requestAnimationFrame(tick);
}
// 再生停止
function stopPlayback(node) {
node._kfPlaying = false;
if (node._kfPlayRaf) { cancelAnimationFrame(node._kfPlayRaf); node._kfPlayRaf = null; }
if (node._kfPlayBtn) {
node._kfPlayBtn.textContent = t("kfPlayBtn");
node._kfPlayBtn.style.background = "#1a2a4a";
}
}
// アニメーションを WebM 動画としてエクスポート
// onProgress(currentFrame, totalFrames) でプログレスコールバック
async function exportVideoWebM(node, onProgress) {
const fps = Math.max(1, node._kfFps || 24);
const total = Math.max(2, node._kfTotalFrames || 60);
const fr = computeOutputFrame(node);
if (!fr) return null;
const { outW, outH } = fr;
const offCanvas = document.createElement("canvas");
offCanvas.width = outW;
offCanvas.height = outH;
const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp9")
? "video/webm;codecs=vp9"
: "video/webm";
const stream = offCanvas.captureStream(0);
const track = stream.getVideoTracks()[0];
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 8_000_000 });
const chunks = [];
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
const savedFrame = node._kfCurrentFrame;
recorder.start();
for (let f = 0; f < total; f++) {
seekToFrame(node, f, { silent: true });
renderToOutputCanvas(node, offCanvas);
track.requestFrame();
onProgress?.(f + 1, total);
// レコーダーにフレームを渡す時間を確保
await new Promise(r => setTimeout(r, 0));
}
recorder.stop();
await new Promise(r => { recorder.onstop = r; });
// 再生位置を元に戻す
seekToFrame(node, savedFrame);
return new Blob(chunks, { type: "video/webm" });
}
// アニメーションを透過 GIF としてエクスポート
// onProgress(currentFrame, totalFrames) でプログレスコールバック
async function exportAnimatedGIF(node, onProgress) {
const { AnimGifEncoder } = await import("./gif_encoder.js");
const fps = Math.max(1, node._kfFps || 24);
const total = Math.max(2, node._kfTotalFrames || 60);
const fr = computeOutputFrame(node);
if (!fr) return null;
const { outW, outH } = fr;
const offCanvas = document.createElement("canvas");
offCanvas.width = outW;
offCanvas.height = outH;
const ctx = offCanvas.getContext("2d");
const enc = new AnimGifEncoder(outW, outH);
enc.setFps(fps);
enc.setQuality(3); // 1=最高品質, 30=最速
const savedFrame = node._kfCurrentFrame;
for (let f = 0; f < total; f++) {
seekToFrame(node, f, { silent: true });
renderToOutputCanvas(node, offCanvas);
enc.addFrame(ctx.getImageData(0, 0, outW, outH));
onProgress?.(f + 1, total);
await new Promise(r => setTimeout(r, 0));
}
seekToFrame(node, savedFrame);
const bytes = enc.encode();
return new Blob([bytes], { type: "image/gif" });
}
// キーフレームアニメーションをライブラリのポーズとしてプロジェクト保存
async function saveKeyframeProject(node) {
const kfs = node._keyframes || [];
if (!kfs.length) { alert(t("kfNoKeyframes")); return false; }
const now = new Date();
const p2 = n => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`;
const defaultName = `project-${ts}`;
const name = prompt(t("kfProjNamePrompt"), defaultName);
if (!name || !name.trim()) return false;
const content = {
_type: "kf_project",
keyframes: JSON.parse(JSON.stringify(kfs)),
kf_total_frames: node._kfTotalFrames || 60,
kf_fps: node._kfFps || 24,
thumbnail: null,
};
// フレーム0でサムネイルを撮影して元に戻す
if (node._nodeCanvas && node._psdW) {
const savedFrame = node._kfCurrentFrame;
seekToFrame(node, 0, { silent: true });
content.thumbnail = captureThumbFromNode(node, 140);
seekToFrame(node, savedFrame, { silent: true });
drawNodeCanvas(node);
}
try {
const res = await fetch("/psd_loader/library/poses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: name.trim(), content }),
});
const d = await res.json();
if (d.error) throw new Error(d.error);
return true;
} catch (e) {
alert(t("poseSaveFailed", e.message));
return false;
}
}
// ================================================
// PSWポーズ補間:PSWハンドル角度からアクティブスロットのポーズを返す
// basePose と合成する(PSWが優先)
// ================================================
function computePswEffectivePose(basePose, pswLayers, editingPswPointId = null, enabled = true) {
if (!enabled) return Object.assign({}, basePose);
const PSW_STEP = Math.PI / 6;
const result = Object.assign({}, basePose);
for (const pswLayer of (pswLayers || [])) {
for (const pswInfo of (pswLayer.points || [])) {
if (pswInfo.id === editingPswPointId) continue; // 編集中はオーバーライドしない
const slots = pswInfo.slots || [{ degree: 0, pose: null }];
const n = slots.length;
if (n === 0) continue;
const angle = pswInfo.angle ?? 0;
const range = PSW_STEP * n;
const normAngle = ((angle % range) + range) % range;
const slotIdx = Math.min(Math.floor(normAngle / PSW_STEP), n - 1);
const slot = slots[slotIdx];
if (slot?.pose) {
for (const [layerId, p] of Object.entries(slot.pose)) {
result[layerId] = Object.assign({}, p);
}
}
}
}
return result;
}
// ================================================
// レイヤー状態管理
// ================================================
class LayerState {
constructor(layers) {
this.visibility = {};
this.renamed = {};
this.customGroups = [];
this.rigging = {}; // { layerId: { r:{x,y}, mr:{x,y}, mr_radius:0 } }
this.pose = {}; // { layerId: { angle:0, tx:0, ty:0 } }
this.swLayers = []; // [{ id, name, points:[{ id, name, x, y, radius, angle, groups:[] }] }]
this.pswLayers = []; // [{ id, name, points:[{ id, name, x, y, radius, angle, slots:[{ degree, pose }] }] }]
this.cgOrder = [...layers].reverse().map(n => n.id);
this.layerParent = {}; // { childId: parentId } レイヤー間の親子関係
this.parentTabOrder = { roots: null, children: {} }; // ペアレントタブ表示順(cgOrderとは独立)
this._walk(layers);
}
_walk(nodes) {
for (const n of nodes) {
this.visibility[n.id] = n.visible;
if (n.children) this._walk(n.children);
}
}
toConfig() {
return {
visibility: { ...this.visibility },
renamed: { ...this.renamed },
custom_groups: this.customGroups.map(g => ({
id: g.id, name: g.name, visible: g.visible, layer_ids: [...g.layer_ids],
})),
cg_order: [...this.cgOrder],
layer_parent: { ...this.layerParent },
parent_tab_order: {
roots: this.parentTabOrder.roots ? [...this.parentTabOrder.roots] : null,
children: JSON.parse(JSON.stringify(this.parentTabOrder.children)),
},
rigging: JSON.parse(JSON.stringify(this.rigging)),
pose: JSON.parse(JSON.stringify(this.pose)),
sw_layers: JSON.parse(JSON.stringify(this.swLayers)),
psw_layers: JSON.parse(JSON.stringify(this.pswLayers)),
};
}
static fromConfig(layers, config) {
const s = new LayerState(layers);
if (config?.visibility) Object.assign(s.visibility, config.visibility);
if (config?.renamed) Object.assign(s.renamed, config.renamed);
if (config?.custom_groups) {
s.customGroups = config.custom_groups.map(g => ({
id: g.id || `cg_${Math.random().toString(36).slice(2)}`,
name: g.name,
visible: g.visible !== false,
layer_ids: [...(g.layer_ids || [])],
}));
}
if (config?.cg_order) {
s.cgOrder = [...config.cg_order];
} else if (s.customGroups.length > 0) {
// 旧形式:ルートCGを先頭、PSDレイヤーを後ろに
const childCgIds = new Set();
for (const cg of s.customGroups) {
for (const lid of cg.layer_ids) if (lid.startsWith('cg_')) childCgIds.add(lid);
}
const rootCgIds = s.customGroups.filter(cg => !childCgIds.has(cg.id)).map(cg => cg.id);
s.cgOrder = [...rootCgIds, ...[...layers].reverse().map(n => n.id)];
}
if (config?.layer_parent) Object.assign(s.layerParent, config.layer_parent);
if (config?.parent_tab_order) {
s.parentTabOrder.roots = config.parent_tab_order.roots ? [...config.parent_tab_order.roots] : null;
s.parentTabOrder.children = JSON.parse(JSON.stringify(config.parent_tab_order.children || {}));
}
if (config?.rigging) Object.assign(s.rigging, JSON.parse(JSON.stringify(config.rigging)));
if (config?.pose) Object.assign(s.pose, JSON.parse(JSON.stringify(config.pose)));
if (config?.sw_layers) {
s.swLayers = JSON.parse(JSON.stringify(config.sw_layers));
} else if (config?.sw && Object.keys(config.sw).length > 0) {
// 旧形式 sw:{layerId:{...}} → swLayers への変換(後方互換)
const swl = { id: `swl_${Math.random().toString(36).slice(2)}`, name: "SW1", points: [] };
for (const [, swInfo] of Object.entries(config.sw)) {
swl.points.push({
id: `swp_${Math.random().toString(36).slice(2)}`,
name: swInfo.name ?? "sw1",
x: swInfo.x ?? 0,
y: swInfo.y ?? 0,
radius: swInfo.radius ?? 80,
angle: swInfo.angle ?? 0,
groups: [...(swInfo.groups ?? [])],
});
}
s.swLayers = [swl];
}
if (config?.psw_layers) {
s.pswLayers = JSON.parse(JSON.stringify(config.psw_layers));
}
return s;
}
}
// ================================================
// モーダル
// ================================================
class PSDModal {
constructor(node, layerTree, existingConfig) {
ensureCSS();
this.node = node;
this.layerTree = JSON.parse(JSON.stringify(layerTree));
this.state = LayerState.fromConfig(this.layerTree, existingConfig);
this.selectedIds = new Set();
this.collapsedIds = new Set();
this._selectedCgId = null;
this._dragSrcId = null;
this._dragSrcCgId = null;
this._activeTab = 'layer';
// リグ関連
this._rigMode = 'normal'; // 'normal' | 'setup' | 'pose'
this._setupPointType = 'r'; // 'r' | 'mr'
this._rigDrag = null; // ドラッグ中: { layerId, type, startWx, startWy, startVal }
this._rigDragging = false;
this._showRigLabels = true; // ポーズモードでのラベル表示フラグ
this._rigPointSize = 1.0; // リグポイントサイズ倍率
this._previewCam = { x: 0, y: 0, zoom: 1 }; // モーダルプレビューカメラ
this._defaultPreviewCam = { x: 0, y: 0, zoom: 1 };
this._overlay = this._buildDOM();
document.body.appendChild(this._overlay);
this._renderTree();
this._initPreview();
}
_buildDOM() {
const overlay = document.createElement("div");
overlay.className = "psd-overlay";
overlay.addEventListener("mousedown", e => { if (e.target === overlay) this.destroy(); });
const modal = document.createElement("div");
modal.className = "psd-modal";
const header = document.createElement("div");
header.className = "psd-modal-header";
const title = document.createElement("span");
title.textContent = t("modalTitle");
// ---- リグモードボタン ----
const rigModeWrap = document.createElement("div");
rigModeWrap.style.cssText = "display:flex;gap:4px;align-items:center;margin:0 8px;";
const mkRigBtn = (label, mode, color) => {
const b = document.createElement("button");
b.className = "psd-btn";
b.textContent = label;
b.title = label;
b.style.cssText = `padding:2px 8px;font-size:11px;background:${color};border-color:${color};`;
b.onclick = () => {
this._rigMode = (this._rigMode === mode) ? 'normal' : mode;
this._updateRigModeUI(setupBar);
};
return b;
};
this._btnSetup = mkRigBtn("Setup", 'setup', "#2a4a6a");
this._btnPose = mkRigBtn("Pose", 'pose', "#2a5a3a");
rigModeWrap.append(this._btnSetup, this._btnPose);
// ---- セットアップ専用バー ----
const setupBar = document.createElement("div");
setupBar.style.cssText = "display:none;gap:4px;align-items:center;padding:4px 8px;background:#1a2a3a;border-bottom:1px solid #313244;flex-wrap:wrap;";
const mkSetupBtn = (label, type, color) => {
const b = document.createElement("button");
b.className = "psd-btn";
b.textContent = label;
b.style.cssText = `padding:2px 8px;font-size:11px;background:${color};border-color:${color};`;
b.onclick = () => { this._setupPointType = type; this._updateSetupBtns(); this._drawPreview(); };
b.dataset.pointType = type;
return b;
};
this._btnR = mkSetupBtn("R", 'r', "#1a3a6a");
this._btnMR = mkSetupBtn("MR", 'mr', "#3a1a1a");
this._btnSW = mkSetupBtn("LSW", 'sw', "#1a3a1a");
this._btnPSW = mkSetupBtn("PSW", 'psw', "#3a1a3a");
this._btnPSW.onclick = () => {
// PSWレイヤーが無ければ自動作成、あれば先頭を選択
if (this.state.pswLayers.length === 0) {
this._createPswLayer();
} else if (!this._selectedPswLayerId) {
this._selectedPswLayerId = this.state.pswLayers[0].id;
if (this._activeTab === 'psw') this._renderPswTab();
}
this._setupPointType = 'psw';
this._updateSetupBtns();
this._drawPreview();
};
const delRigBtn = document.createElement("button");
delRigBtn.className = "psd-btn";
delRigBtn.textContent = t("deleteRigBtn");
delRigBtn.style.cssText = "padding:2px 8px;font-size:11px;";
delRigBtn.title = t("deleteRigTooltip");
delRigBtn.onclick = () => this._deleteSelectedRig();
const setupHint = document.createElement("span");
setupHint.style.cssText = "font-size:10px;color:#888;";
setupHint.textContent = t("setupHint");
setupBar.append(this._btnR, this._btnMR, this._btnSW, this._btnPSW, delRigBtn, setupHint);
this._setupBar = setupBar;
// ---- ポーズ専用バー ----
const poseBar = document.createElement("div");
poseBar.style.cssText = "display:none;gap:4px;align-items:center;padding:4px 8px;background:#1a3a2a;border-bottom:1px solid #313244;";
const lblBtn = document.createElement("button");
lblBtn.className = "psd-btn";
lblBtn.textContent = t("labelBtn");
lblBtn.style.cssText = "padding:2px 8px;font-size:11px;";
lblBtn.title = t("labelBtnTooltip");
lblBtn.onclick = () => {
this._showRigLabels = !this._showRigLabels;
this._updatePoseBarUI();
this._drawPreview();
};
this._lblBtn = lblBtn;
poseBar.appendChild(lblBtn);
this._poseBar = poseBar;
const closeBtn = document.createElement("button");
closeBtn.className = "psd-close-btn";
closeBtn.textContent = "✕";
closeBtn.onclick = () => this.destroy();
header.append(title, rigModeWrap, closeBtn);
const body = document.createElement("div");
body.className = "psd-modal-body";
const previewPanel = document.createElement("div");
previewPanel.className = "psd-preview-panel";
const previewLabel = document.createElement("div");
previewLabel.className = "psd-preview-label";
previewLabel.textContent = t("previewLabel");
this._previewCanvas = document.createElement("canvas");
this._previewCanvas.className = "psd-preview-canvas";
this._previewStatusEl = document.createElement("div");
this._previewStatusEl.className = "psd-preview-status";
const modalRpBtn = document.createElement("button");
modalRpBtn.className = "psd-btn";
modalRpBtn.textContent = "RP";
modalRpBtn.title = t("resetPoseTooltip");
modalRpBtn.style.cssText = "padding:3px 0;font-size:11px;width:100%;margin-top:4px;";
modalRpBtn.onclick = () => {
for (const id of Object.keys(this.state.rigging)) {
this.state.pose[id] = { angle: 0, tx: 0, ty: 0 };
}
this._drawPreview();
};
this._modalRpBtn = modalRpBtn;
// ---- ポイントサイズスライダー(モーダル)----
const modalSliderWrap = document.createElement("div");
modalSliderWrap.style.cssText = "display:flex;align-items:center;gap:6px;padding:4px 0 2px;";
const modalSliderLabel = document.createElement("span");
modalSliderLabel.textContent = "Point Size";
modalSliderLabel.style.cssText = "font-size:11px;color:#cdd6f4;white-space:nowrap;flex-shrink:0;";
const modalSlider = document.createElement("input");
modalSlider.type = "range";
modalSlider.min = "0.5";
modalSlider.max = "3.0";
modalSlider.step = "0.1";
modalSlider.value = "1.0";
modalSlider.style.cssText = "flex:1;accent-color:#4499ff;cursor:pointer;";
const modalSliderVal = document.createElement("span");
modalSliderVal.textContent = "1.0";
modalSliderVal.style.cssText = "font-size:11px;color:#cdd6f4;width:28px;text-align:right;flex-shrink:0;";
modalSlider.addEventListener("input", () => {
this._rigPointSize = parseFloat(modalSlider.value);
modalSliderVal.textContent = parseFloat(modalSlider.value).toFixed(1);
this._drawPreview();
});
modalSliderWrap.append(modalSliderLabel, modalSlider, modalSliderVal);
previewPanel.append(previewLabel, this._previewCanvas, this._previewStatusEl, modalSliderWrap, modalRpBtn);
const rightPanel = document.createElement("div");
rightPanel.className = "psd-right-panel";
// タブバー
const tabBar = document.createElement("div");
tabBar.style.cssText = "display:flex;border-bottom:1px solid #313244;flex-shrink:0;";
const mkTabBtn = label => {
const b = document.createElement("button");
b.className = "psd-btn";
b.textContent = label;
b.style.cssText = "flex:1;border-radius:0;border:none;font-size:12px;padding:5px 8px;outline-offset:-2px;";
return b;
};
const tabLayerBtn = mkTabBtn(t("tabLayer"));
const tabParentBtn = mkTabBtn(t("tabParent"));
const tabSwitchBtn = mkTabBtn(t("tabSwitch"));
const tabPSWBtn = mkTabBtn(t("tabPSwitch"));
tabBar.append(tabLayerBtn, tabParentBtn, tabSwitchBtn, tabPSWBtn);
// レイヤータブ
const layerTabContent = document.createElement("div");
layerTabContent.style.cssText = "display:flex;flex-direction:column;flex:1;overflow:hidden;";
this._layerListEl = document.createElement("div");
this._layerListEl.className = "psd-layer-list";
const layerGroupBar = document.createElement("div");
layerGroupBar.className = "psd-group-bar";
layerGroupBar.append(
this._mkBtn(t("createGroup"), () => this._createGroup()),
this._mkBtn(t("ungroup"), () => this._ungroup()),
this._mkBtn(t("addSwLayer"), () => this._createSwLayer()),
this._mkBtn(t("deleteSwLayer"), () => this._deleteSwLayer()),
this._mkBtn(t("addPswLayer"), () => this._createPswLayer()),
this._mkBtn(t("deletePswLayer"), () => this._deletePswLayer()),
);
layerTabContent.append(this._layerListEl, layerGroupBar);
// ペアレントタブ
const parentTabContent = document.createElement("div");
parentTabContent.style.cssText = "display:none;flex-direction:column;flex:1;overflow:hidden;";
this._parentListEl = document.createElement("div");
this._parentListEl.className = "psd-layer-list";
const parentGroupBar = document.createElement("div");
parentGroupBar.className = "psd-group-bar";
parentGroupBar.append(
this._mkBtn("▲", () => this._shiftParentTabItem(-1)),
this._mkBtn("▼", () => this._shiftParentTabItem(1)),
this._mkBtn("◀", () => this._outdent()),
this._mkBtn("▶", () => this._indent()),
);
parentTabContent.append(this._parentListEl, parentGroupBar);
// スイッチタブ
const switchTabContent = document.createElement("div");
switchTabContent.style.cssText = "display:none;flex-direction:column;flex:1;overflow:hidden;";
this._switchListEl = document.createElement("div");
this._switchListEl.className = "psd-layer-list";
this._switchGroupEl = document.createElement("div");
this._switchGroupEl.style.cssText = "flex:1;overflow-y:auto;padding:4px 8px;font-size:11px;min-height:0;";
const switchBar = document.createElement("div");
switchBar.className = "psd-group-bar";
const makeAddSwBtn = (text, tipKey, mode) => {
const btn = document.createElement("button");
btn.className = "psd-btn";
btn.textContent = text;
btn.title = t(tipKey);
btn.onclick = () => this._addSwGroup(mode);
return btn;
};
const delSwGroupBtn = document.createElement("button");
delSwGroupBtn.className = "psd-btn";
delSwGroupBtn.textContent = "−";
delSwGroupBtn.title = t("deleteGroupTooltip");
delSwGroupBtn.onclick = () => this._removeSwGroup();
switchBar.append(
makeAddSwBtn("+L", "addLayerEntryTooltip", "layer"),
makeAddSwBtn("+P", "addPieceEntryTooltip", "piece"),
makeAddSwBtn("+C", "addCompositeEntryTooltip", "composite"),
delSwGroupBtn
);
switchTabContent.append(this._switchListEl, this._switchGroupEl, switchBar);
this._selectedSwLayerId = null;
this._selectedSwPointInfo = null;
this._selectedSwGroupIdx = -1;
// PSWタブ
const pswTabContent = document.createElement("div");
pswTabContent.style.cssText = "display:none;flex-direction:column;flex:1;overflow:hidden;";
this._pswListEl = document.createElement("div");
this._pswListEl.className = "psd-layer-list";
this._pswSlotListEl = document.createElement("div");
this._pswSlotListEl.style.cssText = "flex:1;overflow-y:auto;padding:4px 8px;font-size:11px;min-height:0;";
const pswBar = document.createElement("div");
pswBar.className = "psd-group-bar";
const mkPswBtn = (text, tipKey, fn, bg, borderCol) => {
const btn = document.createElement("button");
btn.className = "psd-btn";
btn.textContent = text;
btn.title = t(tipKey);
btn.onclick = fn;
if (bg) btn.style.background = bg;
if (borderCol) btn.style.borderColor = borderCol;
return btn;
};
const pswSlotSep = document.createElement("span");
pswSlotSep.style.cssText = "margin-left:8px;display:inline-block;";
pswBar.append(
mkPswBtn("+MLP", "setPswPoseTooltip", () => this._setMlpPose(), "#3a3000", "#7a6500"),
mkPswBtn("+SLP", "setSlpPoseTooltip", () => this._setSlpPose(), "#003040", "#007090"),
mkPswBtn("−LP", "clearPswPoseTooltip", () => this._clearPswPose()),
pswSlotSep,
mkPswBtn("+Slot", "addPswSlotTooltip", () => this._addPswSlot()),
mkPswBtn("−Slot", "deletePswSlotTooltip", () => this._removePswSlot()),
);
pswTabContent.append(this._pswListEl, this._pswSlotListEl, pswBar);
this._selectedPswLayerId = null;
this._selectedPswPointInfo = null;
this._selectedPswSlotIdx = 0;
this._pswEditingPointId = null; // 編集中PSWポイントID(このポイントのPSWオーバーライドを無効化)
this._lastSlpLayerId = null; // +SLP で最後に使ったレイヤーID(スロット間の再選択省略用)
// タブ切り替え
const updateTabStyle = () => {
[
{ btn: tabLayerBtn, key: 'layer' },
{ btn: tabParentBtn, key: 'parent' },
{ btn: tabSwitchBtn, key: 'switch' },
{ btn: tabPSWBtn, key: 'psw' },
].forEach(({ btn, key }) => {
const active = this._activeTab === key;
btn.style.background = active ? "#313244" : "";
btn.style.outline = active ? "2px solid #f38ba8" : "none";
});
layerTabContent.style.display = this._activeTab === 'layer' ? "flex" : "none";
parentTabContent.style.display = this._activeTab === 'parent' ? "flex" : "none";
switchTabContent.style.display = this._activeTab === 'switch' ? "flex" : "none";
pswTabContent.style.display = this._activeTab === 'psw' ? "flex" : "none";
};
tabLayerBtn.onclick = () => { this._activeTab = 'layer'; updateTabStyle(); };
tabParentBtn.onclick = () => { this._activeTab = 'parent'; updateTabStyle(); this._renderTree(); };
tabSwitchBtn.onclick = () => { this._activeTab = 'switch'; updateTabStyle(); this._renderSwitchTab(); };
tabPSWBtn.onclick = () => { this._activeTab = 'psw'; updateTabStyle(); this._renderPswTab(); };
updateTabStyle();
rightPanel.append(tabBar, layerTabContent, parentTabContent, switchTabContent, pswTabContent);
body.append(previewPanel, rightPanel);
const footer = document.createElement("div");
footer.className = "psd-modal-footer";
const poseSaveBtn = this._mkBtn(t("poseBtn"), () => this._savePoseFromModal());
poseSaveBtn.title = t("poseSaveTooltip");
poseSaveBtn.addEventListener("contextmenu", e => { e.preventDefault(); this._savePoseWithSwFromModal(); });
const helpBtn = this._mkBtn(t("helpBtn"), () => this._showHelp());
helpBtn.style.cssText = "margin-right:auto;min-width:28px;padding:5px 10px;font-weight:bold;";
helpBtn.title = t("helpTitle");
footer.append(
helpBtn,
this._mkBtn(t("cancelBtn"), () => this.destroy()),
this._mkBtn(t("saveBtn"), () => this._saveConfig()),
poseSaveBtn,
this._mkBtn(t("applyBtn"), () => this._apply(), true),
);
// ---- ファイル操作バー(常時表示) ----
const fileBar = document.createElement("div");
fileBar.style.cssText = "display:flex;gap:4px;align-items:center;padding:4px 8px;border-bottom:1px solid #313244;flex-shrink:0;";
const modalNewBtn = document.createElement("button");
modalNewBtn.className = "psd-btn";
modalNewBtn.textContent = t("newBtn");
modalNewBtn.title = t("newBtnTooltip");
modalNewBtn.style.cssText = "padding:3px 8px;font-size:11px;flex:1;min-width:0;white-space:nowrap;";
modalNewBtn.onclick = async () => {
if (!confirm(t("confirmNewRig"))) return;
const lw = findWidget(this.node, "layer_config");
if (lw) lw.value = "{}";
this._rigMode = 'normal';
this._updateRigModeUI(this._setupBar);
await this._reloadFromNode();
};
const modalPsdBtn = document.createElement("button");
modalPsdBtn.className = "psd-btn";
{
const fn = this.node._psdFilename || findWidget(this.node, "psd_filename")?.value || "";
modalPsdBtn.textContent = fn ? `📂 ${fn}` : "📂 psd";
}
modalPsdBtn.title = t("selectPsdTooltip");
modalPsdBtn.style.cssText = "padding:3px 8px;font-size:11px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;";
modalPsdBtn.onclick = () => {
const input = getFileInput();
input.onchange = null;
input.onchange = async e => {
const f = e.target.files[0];
if (f) {
await uploadPSD(f, this.node);
const fn = this.node._psdFilename || "";
modalPsdBtn.textContent = fn ? `📂 ${fn}` : "📂 psd";
await this._reloadFromNode();
}
input.value = "";
};
input.click();
};
const modalPsdModelBtn = document.createElement("button");
modalPsdModelBtn.className = "psd-btn";
modalPsdModelBtn.textContent = t("modelBtn");
modalPsdModelBtn.title = t("loadModelTooltip");
modalPsdModelBtn.style.cssText = "padding:3px 8px;font-size:11px;flex-shrink:0;";
modalPsdModelBtn.onclick = () => {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json,.psd-model.json";
inp.onchange = async e => {
const file = e.target.files[0];
if (!file) return;
try {
const data = JSON.parse(await file.text());
if (data.layer_config) {
const lw = findWidget(this.node, "layer_config");
if (lw) lw.value = JSON.stringify(data.layer_config);
}
if (data.psd_filename) {
const fname = data.psd_filename;
const fw = findWidget(this.node, "psd_filename");
if (fw) fw.value = fname;
this.node._psdFilename = fname;
modalPsdBtn.textContent = `📂 ${fname}`;
try {
const res = await fetch(`/psd_loader/layers?filename=${encodeURIComponent(fname)}`);
const ld = await res.json();
if (!ld.error) {
this.node._psdLayers = ld.layers;
this.node._psdW = ld.width;
this.node._psdH = ld.height;
this.node._layerImages = null;
setDefaultCamera(this.node);
}
} catch (_) {}
}
await this._reloadFromNode();
await refreshNodePreview(this.node);
} catch (err) { alert(t("modelFileLoadFailed", err.message)); }
inp.value = "";
};
inp.click();
};
const modalRefreshBtn = document.createElement("button");
modalRefreshBtn.className = "psd-btn";
modalRefreshBtn.textContent = "⟳";
modalRefreshBtn.title = t("refreshTooltip");
modalRefreshBtn.style.cssText = "padding:3px 6px;font-size:11px;flex-shrink:0;";
modalRefreshBtn.onclick = () => { this.node._layerImages = null; refreshNodePreview(this.node); };
fileBar.append(modalNewBtn, modalPsdBtn, modalPsdModelBtn, modalRefreshBtn);
modal.append(header, fileBar, setupBar, poseBar, body, footer);
overlay.appendChild(modal);
return overlay;
}
_showHelp() {
const lang = getLang();
const isJa = lang === 'ja';
const isZh = lang === 'zh';
const sections = isJa ? [
{
title: "基本操作",
rows: [
["PSD 読み込み", "ファイルバーの 📂 psd ボタン、またはキャンバスへのドラッグ&ドロップ"],
["モデル読み込み", "📂 model ボタンで .psd-model.json ファイルを読み込む"],
["設定を保存", "💾 保存 ボタンでモデルファイルに書き出し"],
["適用", "適用 ボタンで layer_config ウィジェットに書き込み、Queue Prompt で出力"],
["プレビュー更新", "⟳ ボタン、またはウィンドウを開き直す"],
],
},
{
title: "レイヤータブ",
rows: [
["表示切替", "レイヤー行の 👁 アイコンをクリック"],
["名前変更", "レイヤー名をダブルクリックして編集"],
["順序変更", "レイヤー行をドラッグ&ドロップ"],
["カスタムグループ作成", "レイヤーを複数選択して「グループ作成」ボタン — Lスイッチタブでレイヤー単位に展開可能"],
["グループ解除", "カスタムグループを選択して「グループ解除」ボタン"],
["LSWレイヤー追加/削除", "「LSW追加」「LSW削除」ボタン(Lスイッチタブで設定)"],
["PSWレイヤー追加/削除", "「PSW追加」「PSW削除」ボタン(Pスイッチタブで設定)"],
["PSWレイヤー名変更", "PSWレイヤー名をダブルクリックして編集(レイヤータブ・Pスイッチタブ共通)"],
],
},
{
title: "ペアレントタブ",
rows: [
["ペアレント設定", "リスト上でアイテムを選択し ▲▼ で順序変更、▶ でインデント(子に)、◀ でアウトデント(親から独立)"],
["目的", "子レイヤーは親レイヤーのトランスフォームを継承してポーズが合成される"],
["⚠ クリッピングレイヤー", "クリッピングレイヤー(✂)とそのベースレイヤーが一緒に動くには、両レイヤーに同じ親を設定してください。ベースレイヤーにのみ親を設定した場合、クリッピングレイヤーは追従しません"],
],
},
{
title: "Lスイッチ(LSW)タブ",
rows: [
["LSWレイヤー選択", "左列でLSWレイヤーを選択すると右列にSWポイントが表示される"],
["LSWポイント追加", "Setupモードで LSW ボタンを押してキャンバスをクリック"],
["エントリ追加", "+L: 個別レイヤーを1スロットとして追加 / +P: グループ/フォルダをメンバーごとに展開して追加(Piece) / +C: グループ/フォルダ全体を合成して1スロットとして追加(Composite)"],
["エントリ削除", "エントリを選択して − ボタンで削除"],
["バッジ", "[L] 個別レイヤー(1スロット) / [P] Pieceグループ(メンバー数スロット) / [C] Compositeグループ(常に1スロット)"],
["スロット展開", "[P]はメンバーレイヤー数分のスロットに展開。例: 3レイヤーグループ = 3スロット(0°・30°・60°)。[C]は常に1スロット"],
["角度表示", "1スロット: 「30°」、複数スロット: 「0°-60°」のように範囲表示"],
["最大スロット数", "全エントリの合計スロット数が12まで"],
["⚠ 孤立エントリ", "グループ/フォルダが解除・削除されると赤背景と ⚠ で表示 → 該当エントリを削除してください"],
["動作", "SWポイントのハンドル角度でアクティブスロットが切り替わる(1スロット = 30°)"],
],
},
{
title: "Pスイッチ(PSW)タブ",
rows: [
["概要", "PSWポイントのハンドル角度に応じて、登録済みポーズ(R/MR状態)を自動的に適用するスイッチ"],
["PSWポイント追加", "Setupモードで PSW ボタンを押し(初回はレイヤー自動作成)、キャンバスをクリック"],
["スロット追加/削除", "+Slot ボタンで30°刻みのスロットを追加(最大12スロット)、−Slot で最後のスロットを削除"],
["0°ポーズ登録 (+MLP)", "0°スロット選択時のみ有効。Poseモードでポーズを決めて +MLP で全レイヤーをまとめて登録(マルチレイヤーポーズ)"],
["0°ポーズ登録 (+SLP)", "0°スロット選択時のみ有効。レイヤーツリーで対象レイヤーを選択し +SLP で1レイヤー分を登録(シングルレイヤーポーズ)"],
["他スロットのポーズ登録", "スロット行をクリックして移動 → ✏編集 でポーズをロード → ポーズ調整 → ✓確定 で保存"],
["ポーズクリア (−LP)", "スロットを選択して −LP ボタンで登録ポーズをクリア"],
["動作", "PSWハンドル角度が 0°〜30°→スロット0、30°〜60°→スロット1… のように切り替わる。可動域は(スロット数−1)×30°でロック"],
["PSW トグル(ノード)", "ノードの Capture ボタン左の PSW ボタンで ON/OFF を切り替え。青=ON(プリセットポーズ適用)、赤=OFF(PSW無効・R/MRで全レイヤー自由操作)"],
["複数PSW", "複数のPSWポイントを配置した場合、それぞれが独立してポーズを合成する"],
],
},
{
title: "Setup モード",
rows: [
["有効化", "ヘッダーの Setup ボタンをクリック(もう一度クリックで通常モードに戻る)"],
["R ポイント(青)", "回転軸。選択レイヤー上でクリックして配置。Pose モードでは回転ドラッグで角度を変える"],
["MR ポイント(赤)", "平行移動軸(可動範囲円あり)。Pose モードで orange ハンドルをドラッグして移動"],
["LSW ポイント(緑)", "レイヤースイッチポイント。ハンドル(水色)を回転させてアクティブスロットを切り替える"],
["PSW ポイント(白)", "ポーズスイッチポイント。ハンドル(紫)を回転させて登録済みポーズを切り替える。PSWボタンを押してキャンバスをクリックで配置"],
["ポイント削除", "対象レイヤーを選択して「🗑 削除」ボタン"],
],
},
{
title: "Pose モード",
rows: [
["有効化", "ヘッダーの Pose ボタンをクリック"],
["回転 (R)", "青いリグポイントをドラッグして回転"],
["移動 (MR)", "orange ハンドルをドラッグして平行移動(可動範囲円内で制限)"],
["スイッチ切替 (LSW)", "水色の LSW ハンドルを回転"],
["ポーズ切替 (PSW)", "紫の PSW ハンドルを回転。各スロットに登録されたポーズが自動適用される"],
["ラベル表示", "🏷 ラベル ボタンでポイント上のレイヤー名を表示/非表示"],
["ポーズ保存", "📷 ポーズ ボタン(右クリック: LSW/PSWハンドル状態込みで保存)。サムネイルはラベル非表示で自動作成"],
["ポーズリセット", "RP ボタンで全ポーズをリセット"],
],
},
{
title: "カメラ操作(モーダルプレビュー)",
rows: [
["ズーム", "マウスホイール"],
["パン", "ドラッグ(何もない場所)"],
["リセット", "RC ボタン"],
],
},
{
title: "カメラ操作(ノードプレビュー)",
rows: [
["ズーム", "マウスホイール"],
["パン", "左ドラッグ"],
["ロール", "Alt+右ドラッグ"],
["カメラリセット", "ノード上の RC ボタン"],
],
},
{
title: "キーフレームアニメーション",
rows: [
["+KF / 🗑KF", "現在フレームにポーズのキーフレームを追加/削除"],
["+CK / -CK", "現在フレームにカメラをキーフレーム登録/削除"],
["↔ キー移動", "ON のときタイムライン上でキーフレームをドラッグ移動(プレイヘッド操作は無効)"],
["0 ボタン", "フレーム 0 に移動"],
["New ボタン", "全キーフレームを削除して初期化(確認あり)"],
["▶ / ■", "アニメーション再生/停止"],
["FPS", "再生・エクスポート時のフレームレート"],
["💾 Proj", "キーフレームアニメーションをライブラリにプロジェクトとして保存"],
["🎬 WebM", "アニメーションを WebM 動画としてエクスポート(Chrome推奨)"],
["🎞️ GIF", "アニメーションを透過 GIF 画像としてエクスポート"],
],
},
] : isZh ? [
{
title: "基本操作",
rows: [
["加载PSD", "点击文件栏的 📂 psd 按钮,或拖放到画布"],
["加载模型", "点击 📂 model 按钮加载 .psd-model.json 文件"],
["保存设置", "点击 💾 保存 按钮导出模型文件"],
["应用", "点击 应用 按钮写入 layer_config,通过 Queue Prompt 输出"],
["刷新预览", "点击 ⟳ 按钮或重新打开窗口"],
],
},
{
title: "图层选项卡",
rows: [
["切换可见性", "点击图层行的 👁 图标"],
["重命名", "双击图层名称进行编辑"],
["调整顺序", "拖放图层行"],
["创建自定义组", "多选图层后点击「创建组」按钮 — 可在L切换选项卡中按图层展开"],
["解除组", "选中自定义组后点击「解除组」按钮"],
["LSW图层", "使用「添加LSW」/「删除LSW」按钮(在L切换选项卡中配置)"],
["PSW图层", "使用「添加PSW」/「删除PSW」按钮(在P切换选项卡中配置)"],
["PSW图层重命名", "双击PSW图层名称进行编辑(图层选项卡/P切换选项卡均可)"],
],
},
{
title: "父级选项卡",
rows: [
["父级设置", "在列表中选择项目,用 ▲▼ 调整顺序,▶ 缩进(设为子级),◀ 取消缩进(独立)"],
["效果", "子图层继承父图层的变换,用于姿势合成"],
["⚠ 剪贴蒙版图层", "剪贴蒙版图层(✂)要随基础图层一起移动,两者需设置相同的父级。若仅基础图层设置了父级,剪贴蒙版图层不会跟随"],
],
},
{
title: "L切换(LSW)选项卡",
rows: [
["选择LSW图层", "在左列选择LSW图层,右列显示SW点"],
["添加LSW点", "在Setup模式下点击 LSW 按钮后点击画布"],
["添加条目", "+L: 将单个图层添加为1个槽位 / +P: 将组/文件夹按图层逐一展开(Piece) / +C: 将组/文件夹整体合成为1个槽位(Composite)"],
["删除条目", "选中条目后点击 − 删除"],
["标记", "[L] 单个图层(1槽位) / [P] Piece组(成员数槽位) / [C] Composite组(始终1槽位)"],
["插槽展开", "[P]按成员图层数展开。例: 3图层的组 = 3个插槽(0°・30°・60°)。[C]始终为1个插槽"],
["最大插槽数", "所有条目的插槽总数最多12个"],
["⚠ 孤立条目", "组/文件夹被解除时显示红色背景和 ⚠ → 请删除该条目"],
["动作", "SW点手柄角度切换当前插槽(1插槽 = 30°)"],
],
},
{
title: "P切换(PSW)选项卡",
rows: [
["概述", "根据PSW点手柄角度,自动切换已注册的姿势(R/MR状态)"],
["添加PSW点", "在Setup模式下点击 PSW 按钮(首次自动创建图层)后点击画布"],
["添加/删除插槽", "+Slot 按钮每次添加30°插槽(最多12个),−Slot 按钮删除最后一个插槽"],
["注册0°姿势 (+MLP)", "仅在选中0°插槽时有效。在Pose模式下摆好姿势,点击 +MLP 将所有图层姿势一并注册(多图层姿势)"],
["注册0°姿势 (+SLP)", "仅在选中0°插槽时有效。在图层树中选中目标图层,点击 +SLP 仅将该图层的姿势注册(单图层姿势)"],
["注册其他插槽姿势", "点击插槽行选中 → 点击 ✏编辑 加载姿势 → 调整姿势 → 点击 ✓确定 保存"],
["清除姿势 (−LP)", "选中插槽后点击 −LP 清除已注册的姿势"],
["动作", "PSW手柄角度 0°~30°→插槽0,30°~60°→插槽1…依次切换。可移动范围锁定为(插槽数−1)×30°"],
["PSW切换(节点)", "节点Capture按钮左侧的PSW按钮可切换ON/OFF。蓝色=ON(应用预设姿势),红色=OFF(禁用PSW,可用R/MR自由操作所有图层)"],
],
},
{
title: "Setup 模式",
rows: [
["启用", "点击标题栏的 Setup 按钮(再次点击退出)"],
["R 点(蓝色)", "旋转轴,点击放置。Pose模式下拖动旋转"],
["MR 点(红色)", "平移轴,Pose模式下拖动橙色手柄移动"],
["LSW 点(绿色)", "图层切换点,旋转水色手柄切换活动插槽"],
["PSW 点(白色)", "姿势切换点,旋转紫色手柄切换已注册的姿势。点击PSW按钮后在画布上点击放置"],
["删除点", "选中图层后点击「🗑 删除」按钮"],
],
},
{
title: "Pose 模式",
rows: [
["启用", "点击标题栏的 Pose 按钮"],
["旋转 (R)", "拖动蓝色绑定点"],
["平移 (MR)", "拖动橙色手柄(限于范围圆内)"],
["切换 (LSW)", "旋转水色 LSW 手柄"],
["姿势切换 (PSW)", "旋转紫色 PSW 手柄,自动应用各插槽注册的姿势"],
["标签显示", "点击 🏷 标签 按钮切换点位标签显示"],
["保存姿势", "点击 📷 姿势 按钮(右键: 含LSW/PSW手柄状态保存)。缩略图自动隐藏标签后生成"],
["重置姿势", "点击 RP 重置所有姿势"],
],
},
{
title: "摄像机操作(编辑器预览)",
rows: [
["缩放", "鼠标滚轮"],
["平移", "拖动(空白区域)"],
["重置", "RC 按钮"],
],
},
{
title: "摄像机操作(节点预览)",
rows: [
["缩放", "鼠标滚轮"],
["平移", "左键拖动"],
["旋转", "Alt+右键拖动"],
["重置摄像机", "节点上的 RC 按钮"],
],
},
{
title: "关键帧动画",
rows: [
["+KF / 🗑KF", "在当前帧添加/删除姿势关键帧"],
["+CK / -CK", "在当前帧注册/删除摄像机关键帧"],
["↔ 键帧移动", "开启时可在时间轴上拖动移动关键帧(禁用播放头操作)"],
["0 按钮", "跳转到第 0 帧"],
["New 按钮", "删除所有关键帧并初始化(需确认)"],
["▶ / ■", "播放/停止动画"],
["FPS", "播放和导出的帧率"],
["💾 Proj", "将关键帧动画作为项目保存到库"],
["🎬 WebM", "将动画导出为WebM视频(推荐Chrome)"],
["🎞️ GIF", "将动画导出为透明GIF图像"],
],
},
] : [
{
title: "Basic Operations",
rows: [
["Load PSD", "Click 📂 psd in the file bar, or drag & drop onto the canvas"],
["Load Model", "Click 📂 model to load a .psd-model.json file"],
["Save", "Click 💾 Save to export a model file"],
["Apply", "Click Apply to write to layer_config, then use Queue Prompt to render"],
["Refresh Preview", "Click ⟳ or reopen the modal"],
],
},
{
title: "Layer Tab",
rows: [
["Toggle Visibility", "Click the 👁 icon on a layer row"],
["Rename", "Double-click the layer name"],
["Reorder", "Drag & drop layer rows"],
["Create Custom Group", "Select multiple layers, then click 'Group' — expandable per-layer in the LSwitch tab"],
["Ungroup", "Select a custom group, then click 'Ungroup'"],
["LSW Layer", "Use 'Add LSW' / 'Del LSW' buttons (configure in LSwitch tab)"],
["PSW Layer", "Use 'Add PSW' / 'Del PSW' buttons (configure in PSwitch tab)"],
["Rename PSW Layer", "Double-click a PSW layer name to edit it (works in both Layer tab and PSwitch tab)"],
],
},
{
title: "Parent Tab",
rows: [
["Parent Setup", "Select an item, use ▲▼ to reorder, ▶ to indent (make child), ◀ to outdent"],
["Effect", "Child layers inherit the parent's transform for pose compositing"],
["⚠ Clipping Layers", "For a clipping layer (✂) to follow its base layer, both must share the same parent. If only the base layer has a parent set, the clipping layer will not follow it"],
],
},
{
title: "LSwitch (LSW) Tab",
rows: [
["Select LSW Layer", "Pick an LSW layer in the left column to see its SW points"],
["Add LSW Point", "In Setup mode, click the LSW button then click the canvas"],
["Add Entry", "+L: add individual layer as 1 slot / +P: add group/folder expanded per-layer (Piece) / +C: add group/folder composited as 1 slot (Composite)"],
["Remove Entry", "Select an entry and click − to remove"],
["Badges", "[L] individual layer (1 slot) / [P] Piece group (N slots) / [C] Composite group (always 1 slot)"],
["Slot Expansion", "[P] expands per member layer. e.g. 3-layer group = 3 slots (0°, 30°, 60°). [C] is always 1 slot"],
["Angle Display", "Single slot: '30°' / Multiple slots: '0°–60°' range notation"],
["Max Slots", "Total slots across all entries: 12"],
["⚠ Orphaned Entry", "Shown in red with ⚠ when the referenced group/folder no longer exists — delete the entry"],
["Behavior", "Handle angle selects the active slot (1 slot = 30°)"],
],
},
{
title: "PSwitch (PSW) Tab",
rows: [
["Overview", "PSW automatically applies a registered pose (R/MR state) based on the PSW handle angle"],
["Add PSW Point", "In Setup mode, click the PSW button (auto-creates a layer on first use) then click the canvas"],
["Add/Remove Slots", "+Slot adds a 30° slot (up to 12 max); −Slot removes the last slot"],
["Register Pose at 0° (+MLP)", "Only at slot 0. In Pose mode, set the desired pose then click +MLP to register all layer poses (Multi-Layer Pose)"],
["Register Pose at 0° (+SLP)", "Only at slot 0. Select a layer in the layer tree then click +SLP to register that layer's pose (Single-Layer Pose)"],
["Register Pose at other slots", "Click a slot row → click ✏Edit to load the pose → adjust → click ✓Confirm to save"],
["Clear Pose (−LP)", "Select a slot and click −LP to clear its registered pose"],
["Behavior", "PSW handle 0°–30°→slot 0, 30°–60°→slot 1, and so on. Movement range is locked to (slot count − 1) × 30°"],
["PSW Toggle (node)", "The PSW button left of Capture on the node toggles ON/OFF. Blue=ON (preset poses active), Red=OFF (PSW disabled — all layers freely controlled by R/MR)"],
["Multiple PSW", "Multiple PSW points can coexist and each applies its pose independently"],
],
},
{
title: "Setup Mode",
rows: [
["Enable", "Click the Setup button in the header (click again to exit)"],
["R Point (blue)", "Rotation pivot. Click to place. In Pose mode, drag to rotate"],
["MR Point (red)", "Translation axis with movement range. In Pose mode, drag orange handle"],
["LSW Point (green)", "Layer switch point. Rotate the cyan handle to change active slot"],
["PSW Point (white)", "Pose switch point. Rotate the purple handle to apply registered poses. Click PSW button then click canvas to place"],
["Delete Rig", "Select the target layer, then click '🗑 Delete'"],
],
},
{
title: "Pose Mode",
rows: [
["Enable", "Click the Pose button in the header"],
["Rotate (R)", "Drag the blue rig point"],
["Translate (MR)", "Drag the orange handle (constrained to range circle)"],
["Layer Switch (LSW)", "Rotate the cyan LSW handle"],
["Pose Switch (PSW)", "Rotate the purple PSW handle — registered poses are applied automatically"],
["Labels", "Toggle layer name labels with 🏷 Labels"],
["Save Pose", "Click 📷 Pose (right-click to include LSW/PSW handle states). Thumbnail is captured with labels hidden automatically"],
["Reset Pose", "Click RP to zero all poses"],
],
},
{
title: "Camera (Editor Preview)",
rows: [
["Zoom", "Mouse wheel"],
["Pan", "Drag on empty area"],
["Reset", "RC button"],
],
},
{
title: "Camera (Node Preview)",
rows: [
["Zoom", "Mouse wheel"],
["Pan", "Left-drag"],
["Roll", "Alt + right-drag"],
["Reset", "Click RC button on the node"],
],
},
{
title: "Keyframe Animation",
rows: [
["+KF / 🗑KF", "Add / remove a pose keyframe at the current frame"],
["+CK / -CK", "Register / remove a camera keyframe at the current frame"],
["↔ Key Move", "When ON, drag keyframes on the timeline (playhead interaction disabled)"],
["0 button", "Jump to frame 0"],
["New button", "Clear all keyframes and reset (confirmation required)"],
["▶ / ■", "Play / stop the animation"],
["FPS", "Frame rate for playback and export"],
["💾 Proj", "Save the keyframe animation to the library as a project"],
["🎬 WebM", "Export animation as a WebM video (Chrome recommended)"],
["🎞️ GIF", "Export animation as a transparent GIF image"],
],
},
];
const overlay = document.createElement("div");
overlay.className = "psd-overlay";
overlay.style.zIndex = "10001";
overlay.addEventListener("mousedown", e => { if (e.target === overlay) overlay.remove(); });
const dialog = document.createElement("div");
dialog.className = "psd-help-dialog";
const header = document.createElement("div");
header.className = "psd-help-header";
const titleEl = document.createElement("span");
titleEl.textContent = t("helpTitle");
const closeBtn = document.createElement("button");
closeBtn.className = "psd-btn";
closeBtn.textContent = t("helpClose");
closeBtn.style.cssText = "padding:3px 12px;font-size:12px;";
closeBtn.onclick = () => overlay.remove();
header.append(titleEl, closeBtn);
const body = document.createElement("div");
body.className = "psd-help-body";
for (const sec of sections) {
const secEl = document.createElement("section");
secEl.className = "psd-help-section";
const h = document.createElement("h3");
h.className = "psd-help-section-title";
h.textContent = sec.title;
secEl.appendChild(h);
const table = document.createElement("table");
table.className = "psd-help-table";
for (const [term, desc] of sec.rows) {
const tr = document.createElement("tr");
const td1 = document.createElement("td");
td1.className = "psd-help-term";
td1.textContent = term;
const td2 = document.createElement("td");
td2.className = "psd-help-desc";
td2.textContent = desc;
tr.append(td1, td2);
table.appendChild(tr);
}
secEl.appendChild(table);
body.appendChild(secEl);
}
dialog.append(header, body);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
_mkBtn(label, onClick, primary = false) {
const b = document.createElement("button");
b.className = "psd-btn" + (primary ? " primary" : "");
b.textContent = label;
b.onclick = onClick;
return b;
}
async _initPreview() {
const node = this.node;
this._previewCanvas.width = MODAL_CANVAS_W;
this._previewCanvas.height = MODAL_CANVAS_H;
if (!node._layerImages) {
const filename = node._psdFilename || findWidget(node, "psd_filename")?.value;
if (!filename) { this._previewStatusEl.textContent = t("psdFileNotSelected"); return; }
this._previewStatusEl.textContent = t("loadingLayerImages");
try {
node._layerImages = await loadLayerImages(filename, this.layerTree);
} catch (e) {
this._previewStatusEl.textContent = t("loadError", e.message);
return;
}
}
this._setDefaultPreviewCam();
this._drawPreview();
this._setupRigInteraction();
this._updateSetupBtns();
}
_getEffectiveImageMap() {
const node = this.node;
const base = node._layerImages || {};
const psdW = node._psdW || 0;
const psdH = node._psdH || 0;
if (!this.state.customGroups.length) return base;
const map = Object.assign(Object.create(null), base);
for (const cg of this.state.customGroups) {
if (!(cg.id in map)) {
map[cg.id] = { img: null, objUrl: null, isCgLayer: true, left: 0, top: 0, width: psdW, height: psdH };
}
}
return map;
}
_setDefaultPreviewCam() {
const node = this.node;
if (!node._psdW || !node._psdH) return;
const W = MODAL_CANVAS_W, H = MODAL_CANVAS_H;
const zoom = Math.min(W / node._psdW, H / node._psdH) * 0.95;
const cam = {
x: (W - node._psdW) / 2,
y: (H - node._psdH) / 2,
zoom,
};
this._previewCam = { ...cam };
this._defaultPreviewCam = { ...cam };
}
_drawPreview() {
const node = this.node;
if (!node._layerImages || !node._psdW) return;
const canvas = this._previewCanvas;
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// チェッカー背景
const gs = 12;
for (let y = 0; y < H; y += gs) {
for (let x = 0; x < W; x += gs) {
ctx.fillStyle = ((Math.floor(x / gs) + Math.floor(y / gs)) % 2 === 0) ? "#2a2a2a" : "#3a3a3a";
ctx.fillRect(x, y, gs, gs);
}
}
const cam = this._previewCam;
const config = this.state.toConfig();
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-W / 2 + cam.x, -H / 2 + cam.y);
const imageMap = this._getEffectiveImageMap();
// PSWポーズオーバーライドを適用(編集中ポイントは除外)
const effectivePose = computePswEffectivePose(
config.pose || {}, config.psw_layers || [], this._pswEditingPointId,
this.node._pswEnabled !== false
);
const renderConfig = Object.assign({}, config, { pose: effectivePose });
renderLayersToCtx(ctx, this.layerTree, imageMap, renderConfig);
// PSDキャンバス境界線(ctx変換内で描画)
if (node._psdW && node._psdH) {
ctx.save();
ctx.strokeStyle = "rgba(255,255,100,0.5)";
ctx.lineWidth = 1 / cam.zoom;
ctx.setLineDash([4 / cam.zoom, 4 / cam.zoom]);
ctx.strokeRect(0, 0, node._psdW, node._psdH);
ctx.restore();
}
// リグオーバーレイ(カメラ変換内で描画)
if (this._rigMode !== 'normal' && (config.rigging || config.sw_layers?.length || config.psw_layers?.length)) {
const selId = this._getSelectedLayerId();
const showLabels = this._rigMode === 'setup' || (this._rigMode === 'pose' && this._showRigLabels);
drawRigOverlay(
ctx, this.layerTree, imageMap,
config.rigging || {}, effectivePose,
this._rigMode, selId, this._setupPointType,
showLabels, config.renamed || {}, config.layer_parent || {},
this.state.customGroups, this._rigPointSize ?? 1.0,
config.sw_layers || [], this._selectedSwPointInfo ?? null,
config.psw_layers || [], this._selectedPswPointInfo ?? null
);
}
ctx.restore();
// --- 出力フレームオーバーレイ(半透明緑線、常時表示) ---
const mof = this._computeModalOutputFrame();
if (mof) {
ctx.save();
ctx.strokeStyle = "rgba(0,220,80,0.65)";
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.strokeRect(Math.round(mof.sx) + 0.5, Math.round(mof.sy) + 0.5, Math.round(mof.sw) - 1, Math.round(mof.sh) - 1);
ctx.restore();
}
this._previewStatusEl.textContent = "";
}
_computeModalOutputFrame() {
const node = this.node;
if (!node._psdW || !node._psdH || !this._previewCam) return null;
const psdW = node._psdW, psdH = node._psdH;
const outW = parseInt(findWidget(node, "output_width")?.value) || 512;
const outH = parseInt(findWidget(node, "output_height")?.value) || 512;
const outAspect = outW / outH;
const psdAspect = psdW / psdH;
let fw, fh;
if (outAspect >= psdAspect) { fw = psdW; fh = psdW / outAspect; }
else { fh = psdH; fw = psdH * outAspect; }
const fpx = (psdW - fw) / 2;
const fpy = (psdH - fh) / 2;
const cam = this._previewCam;
const W = this._previewCanvas.width, H = this._previewCanvas.height;
const toS = (px, py) => ({
sx: (px - W / 2 + cam.x) * cam.zoom + W / 2,
sy: (py - H / 2 + cam.y) * cam.zoom + H / 2,
});
const tl = toS(fpx, fpy);
const br = toS(fpx + fw, fpy + fh);
return { sx: tl.sx, sy: tl.sy, sw: br.sx - tl.sx, sh: br.sy - tl.sy, outW, outH };
}
_updateRigModeUI(setupBar) {
this._btnSetup.style.outline = (this._rigMode === 'setup') ? `2px solid #4499ff` : "none";
this._btnPose.style.outline = (this._rigMode === 'pose') ? `2px solid #44ff88` : "none";
this._setupBar.style.display = (this._rigMode === 'setup') ? "flex" : "none";
this._poseBar.style.display = (this._rigMode === 'pose') ? "flex" : "none";
this._updatePoseBarUI();
this._drawPreview();
}
_updatePoseBarUI() {
if (!this._lblBtn) return;
this._lblBtn.style.outline = this._showRigLabels ? "2px solid #44ff88" : "none";
}
_updateSetupBtns() {
this._btnR.style.outline = (this._setupPointType === 'r') ? "2px solid #4499ff" : "none";
this._btnMR.style.outline = (this._setupPointType === 'mr') ? "2px solid #ff3333" : "none";
this._btnSW.style.outline = (this._setupPointType === 'sw') ? "2px solid #44cc44" : "none";
this._btnPSW.style.outline = (this._setupPointType === 'psw') ? "2px solid #cc88ff" : "none";
}
_deleteSelectedRig() {
const ids = this._selectedCgId ? [this._selectedCgId] : [...this.selectedIds];
for (const id of ids) {
delete this.state.rigging[id];
delete this.state.pose[id];
}
this._drawPreview();
}
// ---- SW(スイッチ)関連メソッド ----
_nextSwPointName() {
let idx = 1;
const used = new Set();
for (const l of this.state.swLayers) for (const p of l.points) used.add(p.name);
while (used.has(`sw${idx}`)) idx++;
return `sw${idx}`;
}
_placeSwPoint(wx, wy) {
const swLayer = this.state.swLayers.find(l => l.id === this._selectedSwLayerId);
if (!swLayer) return;
const pt = {
id: `swp_${Math.random().toString(36).slice(2)}`,
name: this._nextSwPointName(),
x: Math.round(wx),
y: Math.round(wy),
radius: 80,
angle: 0,
groups: [],
};
swLayer.points.push(pt);
this._selectedSwPointInfo = { swLayerId: swLayer.id, pointId: pt.id };
// 配置後SWボタンを解除
this._setupPointType = null;
this._updateSetupBtns();
if (this._activeTab === 'switch') this._renderSwitchTab();
this._renderLayerTab();
}
_renderSwitchTab() {
if (!this._switchListEl || !this._switchGroupEl) return;
this._switchListEl.innerHTML = "";
for (const swLayer of this.state.swLayers) {
// SWレイヤーヘッダー
const layerRow = document.createElement("div");
layerRow.style.cssText = "padding:3px 8px;font-size:11px;color:#55aaff;background:#0a1020;border-left:3px solid #2266cc;display:flex;align-items:center;gap:4px;";
layerRow.textContent = `${t("swPrefix")} ${swLayer.name}`;
this._switchListEl.appendChild(layerRow);
for (const pt of swLayer.points) {
const isPtSel = this._selectedSwPointInfo?.swLayerId === swLayer.id
&& this._selectedSwPointInfo?.pointId === pt.id;
const row = document.createElement("div");
row.className = "psd-layer-item" + (isPtSel ? " selected" : "");
row.style.cssText = "padding:3px 10px 3px 20px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:4px;";
// SWポイント名(ダブルクリックで名前変更)
const nameEl = document.createElement("span");
nameEl.className = "layer-name";
nameEl.textContent = pt.name;
nameEl.title = t("clickSelectDblRename");
let clickTimer = null;
nameEl.addEventListener("click", e => {
e.stopPropagation();
if (clickTimer) return;
clickTimer = setTimeout(() => {
clickTimer = null;
this._selectedSwPointInfo = isPtSel ? null : { swLayerId: swLayer.id, pointId: pt.id };
this._renderSwitchTab();
this._drawPreview();
}, 250);
});
nameEl.addEventListener("dblclick", e => {
e.stopPropagation();
clearTimeout(clickTimer); clickTimer = null;
this._selectedSwPointInfo = { swLayerId: swLayer.id, pointId: pt.id };
const input = document.createElement("input");
input.value = pt.name;
input.style.cssText = "width:90px;background:#313244;border:1px solid #89b4fa;border-radius:3px;color:#cdd6f4;padding:1px 6px;font-size:12px;outline:none;";
nameEl.replaceWith(input); input.focus(); input.select();
const commit = () => {
if (!input.isConnected) return;
const v = input.value.trim(); if (v) pt.name = v;
nameEl.textContent = pt.name; input.replaceWith(nameEl); this._drawPreview();
};
input.addEventListener("blur", commit);
input.addEventListener("keydown", ev => { if (ev.key === "Enter") commit(); if (ev.key === "Escape") input.replaceWith(nameEl); });
});
// SWポイント削除ボタン
const delBtn = document.createElement("button");
delBtn.className = "psd-btn";
delBtn.textContent = "✕";
delBtn.style.cssText = "padding:1px 5px;font-size:10px;margin-left:auto;flex-shrink:0;";
delBtn.title = t("deleteSwPointTooltip");
delBtn.onclick = e => {
e.stopPropagation();
swLayer.points = swLayer.points.filter(p => p.id !== pt.id);
if (isPtSel) this._selectedSwPointInfo = null;
this._renderSwitchTab();
this._renderLayerTab();
this._drawPreview();
};
row.append(nameEl, delBtn);
row.addEventListener("click", e => {
if (e.target === nameEl) return;
this._selectedSwPointInfo = isPtSel ? null : { swLayerId: swLayer.id, pointId: pt.id };
this._renderSwitchTab();
this._drawPreview();
});
this._switchListEl.appendChild(row);
// 選択中SWポイントのgroupsリスト展開
if (isPtSel) {
let slotOffset = 0;
for (let i = 0; i < pt.groups.length; i++) {
const entry = pt.groups[i];
const isPsdGroup = entry?.type === 'psd_group';
const isCustomGroup = entry?.type === 'custom_group';
const isComposite = (isPsdGroup || isCustomGroup) && entry?.mode === 'composite';
const cgObj = isCustomGroup
? this.state.customGroups.find(g => g.id === entry.id) ?? null
: null;
const leaves = isPsdGroup ? getPsdGroupLeaves(entry.id, this.layerTree)
: isCustomGroup ? (cgObj?.layer_ids || [])
: null;
const slotCount = isComposite ? 1
: (isPsdGroup || isCustomGroup) ? (leaves?.length ?? 0) : 1;
const isOrphaned = (isPsdGroup || isCustomGroup) && (leaves?.length ?? 0) === 0;
const isGSel = i === this._selectedSwGroupIdx;
const gRow = document.createElement("div");
gRow.className = "psd-layer-item" + (isGSel ? " selected" : "");
gRow.style.cssText = "padding:3px 10px 3px 32px;cursor:pointer;font-size:11px;display:flex;align-items:center;gap:4px;"
+ (isOrphaned ? "background:rgba(180,40,40,0.18);outline:1px solid rgba(200,60,60,0.5);" : "");
const typeBadge = document.createElement("span");
typeBadge.style.cssText = "flex-shrink:0;font-size:10px;font-weight:bold;padding:1px 3px;border-radius:2px;margin-right:2px;";
if (typeof entry === 'string') {
typeBadge.textContent = "L"; typeBadge.style.background = "#2a2a4a"; typeBadge.style.color = "#89b4fa";
} else if (isComposite) {
typeBadge.textContent = "C"; typeBadge.style.background = "#3a2515"; typeBadge.style.color = "#fab387";
} else {
typeBadge.textContent = "P"; typeBadge.style.background = "#152535"; typeBadge.style.color = "#89dceb";
}
const stepLabel = document.createElement("span");
stepLabel.style.cssText = "color:#888;flex-shrink:0;width:52px;text-align:right;font-size:10px;";
if (isOrphaned) {
stepLabel.textContent = "-";
stepLabel.style.color = "#cc4444";
} else if (!isComposite && (isPsdGroup || isCustomGroup) && slotCount > 1) {
stepLabel.textContent = `${slotOffset * 30}°-${(slotOffset + slotCount - 1) * 30}°`;
} else {
stepLabel.textContent = `${slotOffset * 30}°`;
}
const entryId = typeof entry === 'string' ? entry : entry?.id;
const entryVal = isPsdGroup ? `psd_group:${entryId}`
: isCustomGroup ? `cg:${entryId}`
: entryId;
const cgSel = document.createElement("select");
cgSel.style.cssText = "flex:1;background:#252535;border:1px solid #4a4a66;color:#cdd6f4;padding:2px 4px;border-radius:3px;font-size:11px;";
if (typeof entry === 'string') {
// +L エントリ: レイヤーのみ
const addLayerOpts = (nodes) => {
for (const n of nodes) {
if (n.kind === "group" && n.children) { addLayerOpts(n.children); continue; }
const opt = document.createElement("option");
const clipMark = n.clipping ? " ✂" : "";
opt.value = n.id; opt.textContent = `${t("layerPrefix")} ${this.state.renamed[n.id] ?? n.name}${clipMark}`;
if (n.id === entryVal) opt.selected = true;
cgSel.appendChild(opt);
}
};
addLayerOpts(this.layerTree);
} else {
// +P / +C エントリ: グループ/フォルダのみ
for (const cg2 of this.state.customGroups) {
const opt = document.createElement("option");
const cgVal = `cg:${cg2.id}`;
opt.value = cgVal; opt.textContent = `${t("groupPrefix")} ${cg2.name}`;
if (cgVal === entryVal) opt.selected = true;
cgSel.appendChild(opt);
}
const addGroupOpts = (nodes) => {
for (const n of nodes) {
if (n.kind === 'group' && n.children) {
const opt = document.createElement("option");
const psdVal = `psd_group:${n.id}`;
opt.value = psdVal;
opt.textContent = `${t("psdGroupPrefix")} ${this.state.renamed[n.id] ?? n.name}`;
if (psdVal === entryVal) opt.selected = true;
cgSel.appendChild(opt);
addGroupOpts(n.children);
}
}
};
addGroupOpts(this.layerTree);
}
cgSel.addEventListener("change", () => {
const val = cgSel.value;
const entryMode = entry?.mode ?? 'piece';
if (val.startsWith('psd_group:')) {
const gid2 = val.slice('psd_group:'.length);
const tempGroups = [...pt.groups];
tempGroups[i] = { type: 'psd_group', id: gid2, mode: entryMode };
if (countSwSlots(tempGroups, this.layerTree, this.state.customGroups) > 12) {
alert(t("maxGroupsReached"));
cgSel.value = entryVal;
return;
}
pt.groups[i] = { type: 'psd_group', id: gid2, mode: entryMode };
} else if (val.startsWith('cg:')) {
const cgId = val.slice(3);
const tempGroups = [...pt.groups];
tempGroups[i] = { type: 'custom_group', id: cgId, mode: entryMode };
if (countSwSlots(tempGroups, this.layerTree, this.state.customGroups) > 12) {
alert(t("maxGroupsReached"));
cgSel.value = entryVal;
return;
}
pt.groups[i] = { type: 'custom_group', id: cgId, mode: entryMode };
} else {
pt.groups[i] = val;
}
this._renderSwitchTab();
this._drawPreview();
});
cgSel.addEventListener("click", e => e.stopPropagation());
gRow.addEventListener("click", () => {
this._selectedSwGroupIdx = isGSel ? -1 : i;
this._renderSwitchTab();
});
if (isOrphaned) {
const warn = document.createElement("span");
warn.textContent = "⚠";
warn.title = t("swGroupOrphaned");
warn.style.cssText = "color:#cc4444;flex-shrink:0;font-size:11px;";
gRow.append(typeBadge, stepLabel, cgSel, warn);
} else {
gRow.append(typeBadge, stepLabel, cgSel);
}
this._switchListEl.appendChild(gRow);
slotOffset += slotCount;
}
}
}
}
this._switchGroupEl.innerHTML = "";
}
_addSwGroup(mode) {
const info = this._selectedSwPointInfo;
if (!info) return;
const swLayer = this.state.swLayers.find(l => l.id === info.swLayerId);
const pt = swLayer?.points?.find(p => p.id === info.pointId);
if (!pt) return;
if (countSwSlots(pt.groups, this.layerTree, this.state.customGroups) >= 12) { alert(t("maxGroupsReached")); return; }
if (mode === 'layer') {
const find = ns => { for (const n of ns) { if (n.kind !== "group") return n; if (n.children) { const f = find(n.children); if (f) return f; } } return null; };
const firstLayer = find(this.layerTree);
if (!firstLayer) { alert(t("noAssignableLayer")); return; }
pt.groups.push(firstLayer.id);
} else {
const firstCg = this.state.customGroups[0];
const findGrp = ns => { for (const n of ns) { if (n.kind === 'group' && n.children) return n; if (n.children) { const f = findGrp(n.children); if (f) return f; } } return null; };
const firstPsdGroup = findGrp(this.layerTree);
if (!firstCg && !firstPsdGroup) { alert(t("noAssignableGroup")); return; }
pt.groups.push(firstCg
? { type: 'custom_group', id: firstCg.id, mode }
: { type: 'psd_group', id: firstPsdGroup.id, mode });
}
this._selectedSwGroupIdx = pt.groups.length - 1;
this._renderSwitchTab();
this._drawPreview();
}
_removeSwGroup() {
const info = this._selectedSwPointInfo;
if (!info) return;
const swLayer = this.state.swLayers.find(l => l.id === info.swLayerId);
const pt = swLayer?.points?.find(p => p.id === info.pointId);
if (!pt) return;
const idx = this._selectedSwGroupIdx;
if (idx < 0 || idx >= pt.groups.length) return;
pt.groups.splice(idx, 1);
this._selectedSwGroupIdx = Math.min(idx, pt.groups.length - 1);
this._renderSwitchTab();
this._drawPreview();
}
_getSelectedLayerId() {
return this._selectedCgId ?? [...this.selectedIds][0] ?? null;
}
// セットアップ/ポージング モードのインタラクション設定(パン・ズーム含む)
_setupRigInteraction() {
const canvas = this._previewCanvas;
let dragInfo = null;
let isPanning = false;
let lastMX = 0, lastMY = 0;
// canvas CSS座標 → PSD(ワールド)座標(カメラ変換を逆算)
const toPsd = (e) => {
const rect = canvas.getBoundingClientRect();
const sx = (e.clientX - rect.left) * (canvas.width / rect.width);
const sy = (e.clientY - rect.top) * (canvas.height / rect.height);
const cam = this._previewCam;
const W = canvas.width, H = canvas.height;
return {
x: (sx - W / 2) / cam.zoom + W / 2 - cam.x,
y: (sy - H / 2) / cam.zoom + H / 2 - cam.y,
};
};
canvas.addEventListener("mousedown", (e) => {
lastMX = e.clientX; lastMY = e.clientY;
if (this._rigMode === 'normal') {
isPanning = true;
canvas.style.cursor = "grabbing";
e.preventDefault(); e.stopPropagation();
return;
}
const { x: wx, y: wy } = toPsd(e);
if (this._rigMode === 'setup') {
if (e.button === 2) { isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault(); return; }
const imageMap = this._getEffectiveImageMap();
if (this._setupPointType === 'sw') {
// SWモード: SWポイントへのヒットテスト(ドラッグ)or 新規配置
const hit = hitTestRig(wx, wy, this.layerTree, imageMap,
this.state.rigging, this.state.pose, 'setup', this._previewCam.zoom,
this.state.layerParent, this.state.customGroups, this._rigPointSize ?? 1.0, this.state.swLayers);
if (hit && (hit.type === 'sw_handle' || hit.type === 'sw_origin')) {
this._selectedSwPointInfo = { swLayerId: hit.swLayerId, pointId: hit.swPointId };
dragInfo = { mode: 'setup_drag', hit };
canvas.style.cursor = "crosshair";
e.preventDefault(); e.stopPropagation();
} else if (this._selectedSwPointInfo) {
// 選択済みSWポイントの原点付近をクリックした場合は移動ドラッグ開始
const selInfo = this._selectedSwPointInfo;
const selSwL = this.state.swLayers.find(l => l.id === selInfo.swLayerId);
const selPt = selSwL?.points?.find(p => p.id === selInfo.pointId);
const bonusR = 28 / (this._previewCam.zoom ?? 1) * (this._rigPointSize ?? 1.0);
if (selPt && Math.hypot(wx - (selPt.x ?? 0), wy - (selPt.y ?? 0)) < bonusR) {
dragInfo = { mode: 'setup_drag', hit: { swLayerId: selInfo.swLayerId, swPointId: selInfo.pointId, type: 'sw_origin' } };
canvas.style.cursor = "crosshair";
e.preventDefault(); e.stopPropagation();
} else if (this._selectedSwLayerId) {
this._placeSwPoint(wx, wy);
this._drawPreview();
e.preventDefault(); e.stopPropagation();
} else {
isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault();
}
} else if (this._selectedSwLayerId) {
this._placeSwPoint(wx, wy);
this._drawPreview();
e.preventDefault(); e.stopPropagation();
} else {
isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault();
}
} else if (this._setupPointType === 'psw') {
// PSWモード: PSWポイントへのヒットテスト(ドラッグ)or 新規配置
const hit = hitTestRig(wx, wy, this.layerTree, imageMap,
this.state.rigging, this.state.pose, 'setup', this._previewCam.zoom,
this.state.layerParent, this.state.customGroups, this._rigPointSize ?? 1.0,
this.state.swLayers, this.state.pswLayers);
if (hit && (hit.type === 'psw_handle' || hit.type === 'psw_origin')) {
this._selectedPswPointInfo = { pswLayerId: hit.pswLayerId, pointId: hit.pswPointId };
dragInfo = { mode: 'setup_drag', hit };
canvas.style.cursor = "crosshair";
e.preventDefault(); e.stopPropagation();
} else if (this._selectedPswPointInfo) {
const selInfo = this._selectedPswPointInfo;
const selPswL = this.state.pswLayers.find(l => l.id === selInfo.pswLayerId);
const selPt = selPswL?.points?.find(p => p.id === selInfo.pointId);
const bonusR = 28 / (this._previewCam.zoom ?? 1) * (this._rigPointSize ?? 1.0);
if (selPt && Math.hypot(wx - (selPt.x ?? 0), wy - (selPt.y ?? 0)) < bonusR) {
dragInfo = { mode: 'setup_drag', hit: { pswLayerId: selInfo.pswLayerId, pswPointId: selInfo.pointId, type: 'psw_origin' } };
canvas.style.cursor = "crosshair";
e.preventDefault(); e.stopPropagation();
} else if (this._selectedPswLayerId) {
this._placePswPoint(wx, wy);
this._drawPreview();
e.preventDefault(); e.stopPropagation();
} else {
isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault();
}
} else if (this._selectedPswLayerId) {
this._placePswPoint(wx, wy);
this._drawPreview();
e.preventDefault(); e.stopPropagation();
} else {
isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault();
}
} else {
const selId = this._getSelectedLayerId();
if (!selId) { isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault(); return; }
const entry = imageMap[selId];
if (!entry) { isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault(); return; }
const hit = hitTestRig(wx, wy, this.layerTree, imageMap,
this.state.rigging, this.state.pose, 'setup', this._previewCam.zoom,
this.state.layerParent, this.state.customGroups, this._rigPointSize ?? 1.0, this.state.swLayers);
if (hit && hit.layerId === selId) {
dragInfo = { mode: 'setup_drag', hit };
canvas.style.cursor = "crosshair";
e.preventDefault(); e.stopPropagation();
} else {
this._placeSetupPoint(selId, wx, wy, entry);
this._drawPreview();
e.preventDefault(); e.stopPropagation();
}
}
} else if (this._rigMode === 'pose') {
const imageMap = this._getEffectiveImageMap();
const hit = hitTestRig(wx, wy, this.layerTree, imageMap,
this.state.rigging, this.state.pose, 'pose', this._previewCam.zoom,
this.state.layerParent, this.state.customGroups, this._rigPointSize ?? 1.0,
this.state.swLayers, this.state.pswLayers);
if (hit && (e.altKey || e.ctrlKey) && (hit.type === 'r' || hit.type === 'mr')) {
if (!this.state.pose[hit.layerId]) this.state.pose[hit.layerId] = { angle: 0, tx: 0, ty: 0 };
const p = this.state.pose[hit.layerId];
if (e.altKey) p.flipX = !p.flipX;
if (e.ctrlKey) p.flipY = !p.flipY;
this._drawPreview();
e.preventDefault(); e.stopPropagation();
} else if (hit && hit.type === 'sw_handle') {
// SWハンドル: 角度を変えてグループ切り替え
const swLayer = this.state.swLayers.find(l => l.id === hit.swLayerId);
const swInfo = swLayer?.points?.find(p => p.id === hit.swPointId);
if (swInfo) {
this._selectedSwPointInfo = { swLayerId: hit.swLayerId, pointId: hit.swPointId };
dragInfo = { mode: 'sw_pose_drag', hit,
pivotX: swInfo.x ?? 0,
pivotY: swInfo.y ?? 0 };
canvas.style.cursor = "crosshair";
e.preventDefault(); e.stopPropagation();
} else { isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault(); e.stopPropagation(); }
} else if (hit && hit.type === 'psw_handle') {
// PSWハンドル: 角度を変えてポーズ切り替え
const pswLayer = this.state.pswLayers.find(l => l.id === hit.pswLayerId);
const pswInfo = pswLayer?.points?.find(p => p.id === hit.pswPointId);
if (pswInfo) {
this._selectedPswPointInfo = { pswLayerId: hit.pswLayerId, pointId: hit.pswPointId };
dragInfo = { mode: 'psw_pose_drag', hit,
pivotX: pswInfo.x ?? 0,
pivotY: pswInfo.y ?? 0 };
canvas.style.cursor = "crosshair";
e.preventDefault(); e.stopPropagation();
} else { isPanning = true; canvas.style.cursor = "grabbing"; e.preventDefault(); e.stopPropagation(); }
} else if (hit) {
const rig = this.state.rigging[hit.layerId];
const entry = imageMap[hit.layerId];
const p = this.state.pose[hit.layerId] || { angle: 0, tx: 0, ty: 0 };
let pivotX = 0, pivotY = 0, initAngle = 0;
// 子レイヤーは祖先トランスフォーム適用後の視覚座標をピボットに使う
const chain = buildAncestorChain(hit.layerId, this.state.layerParent,
this.state.rigging, this.state.pose, imageMap);
if (hit.type === 'r' && rig.r) {
const local = { x: entry.left + rig.r.x + (p.tx ?? 0), y: entry.top + rig.r.y + (p.ty ?? 0) };
const vis = chain.length > 0 ? applyChainToPoint(local.x, local.y, chain) : local;
pivotX = vis.x; pivotY = vis.y;
initAngle = Math.atan2(wy - pivotY, wx - pivotX);
} else if (hit.type === 'mr' && rig.mr) {
const local = { x: entry.left + rig.mr.x, y: entry.top + rig.mr.y };
const vis = chain.length > 0 ? applyChainToPoint(local.x, local.y, chain) : local;
pivotX = vis.x; pivotY = vis.y;
initAngle = Math.atan2(wy - pivotY, wx - pivotX);
}
dragInfo = { mode: 'pose_drag', hit, pivotX, pivotY,
initAngle, startAngle: p.angle ?? 0,
startTx: p.tx ?? 0, startTy: p.ty ?? 0 };
canvas.style.cursor = "crosshair";
e.preventDefault(); e.stopPropagation();
} else {
isPanning = true;
canvas.style.cursor = "grabbing";
e.preventDefault(); e.stopPropagation();
}
}
});
canvas.addEventListener("mousemove", (e) => {
if (isPanning) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const cam = this._previewCam;
cam.x += (e.clientX - lastMX) * scaleX / cam.zoom;
cam.y += (e.clientY - lastMY) * scaleY / cam.zoom;
lastMX = e.clientX; lastMY = e.clientY;
this._drawPreview();
return;
}
if (!dragInfo) return;
const { x: wx, y: wy } = toPsd(e);
if (dragInfo.mode === 'setup_drag') {
const { hit } = dragInfo;
if (hit.type === 'sw_handle' || hit.type === 'sw_origin') {
// SWポイントのドラッグ
const swLayer = this.state.swLayers.find(l => l.id === hit.swLayerId);
const sw = swLayer?.points?.find(p => p.id === hit.swPointId);
if (sw) {
if (hit.type === 'sw_handle') {
const oX = sw.x ?? 0;
const oY = sw.y ?? 0;
sw.radius = Math.max(20, Math.round(Math.hypot(wx - oX, wy - oY)));
sw.angle = Math.atan2(wy - oY, wx - oX);
} else {
sw.x = Math.round(wx);
sw.y = Math.round(wy);
}
}
} else if (hit.type === 'psw_handle' || hit.type === 'psw_origin') {
// PSWポイントのドラッグ
const pswLayer = this.state.pswLayers.find(l => l.id === hit.pswLayerId);
const psw = pswLayer?.points?.find(p => p.id === hit.pswPointId);
if (psw) {
if (hit.type === 'psw_handle') {
const oX = psw.x ?? 0;
const oY = psw.y ?? 0;
psw.radius = Math.max(20, Math.round(Math.hypot(wx - oX, wy - oY)));
psw.angle = Math.atan2(wy - oY, wx - oX);
} else {
psw.x = Math.round(wx);
psw.y = Math.round(wy);
}
}
} else {
const selId = hit.layerId;
const entry = this._getEffectiveImageMap()[selId];
if (!entry) return;
if (!this.state.rigging[selId]) this.state.rigging[selId] = {
r: { x: entry.width / 2, y: entry.height / 2 },
mr: { x: entry.width / 2, y: entry.height / 2 },
mr_radius: 0,
};
const rig = this.state.rigging[selId];
if (hit.type === 'r') {
rig.r.x = wx - entry.left;
rig.r.y = wy - entry.top;
} else if (hit.type === 'mr') {
rig.mr.x = wx - entry.left;
rig.mr.y = wy - entry.top;
} else if (hit.type === 'orange') {
const sX = entry.left + rig.mr.x;
const sY = entry.top + rig.mr.y;
const dx = wx - sX, dy = wy - sY;
rig.mr_radius = Math.max(0, Math.round(Math.hypot(dx, dy)));
if (rig.mr_radius > 0) rig.mr_angle = Math.atan2(dy, dx);
}
}
this._drawPreview();
} else if (dragInfo.mode === 'pose_drag') {
const { hit, pivotX, pivotY, initAngle, startAngle, startTx, startTy } = dragInfo;
const layerId = hit.layerId;
if (!this.state.pose[layerId]) this.state.pose[layerId] = { angle: 0, tx: 0, ty: 0 };
const p = this.state.pose[layerId];
const rig = this.state.rigging[layerId];
const imageMap = this._getEffectiveImageMap();
const entry = imageMap[layerId];
if (hit.type === 'r' || hit.type === 'mr') {
const curAngle = Math.atan2(wy - pivotY, wx - pivotX);
p.angle = startAngle + (curAngle - initAngle);
} else if (hit.type === 'orange') {
const rawSX = entry.left + rig.mr.x;
const rawSY = entry.top + rig.mr.y;
// 祖先トランスフォームがある場合、ドラッグ位置を祖先ローカル空間に逆変換
const dChain = buildAncestorChain(layerId, this.state.layerParent,
this.state.rigging, this.state.pose, imageMap);
let localWx = wx, localWy = wy;
if (dChain.length > 0) {
const inv = inverseChainToPoint(wx, wy, dChain);
localWx = inv.x; localWy = inv.y;
}
let newTx = localWx - rawSX;
let newTy = localWy - rawSY;
if (rig.mr_radius > 0) {
const dist = Math.hypot(newTx, newTy);
if (dist > rig.mr_radius) {
newTx = newTx / dist * rig.mr_radius;
newTy = newTy / dist * rig.mr_radius;
}
}
p.tx = newTx;
p.ty = newTy;
}
this._drawPreview();
} else if (dragInfo.mode === 'sw_pose_drag') {
const { hit, pivotX, pivotY } = dragInfo;
const swLayer = this.state.swLayers.find(l => l.id === hit.swLayerId);
const swInfo = swLayer?.points?.find(p => p.id === hit.swPointId);
if (swInfo) {
swInfo.angle = Math.atan2(wy - pivotY, wx - pivotX);
this._drawPreview();
}
} else if (dragInfo.mode === 'psw_pose_drag') {
const { hit, pivotX, pivotY } = dragInfo;
const pswLayer = this.state.pswLayers.find(l => l.id === hit.pswLayerId);
const pswInfo = pswLayer?.points?.find(p => p.id === hit.pswPointId);
if (pswInfo) {
const _raw = Math.atan2(wy - pivotY, wx - pivotX);
const _norm = ((_raw % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
pswInfo.angle = Math.min(_norm, (Math.PI / 6) * Math.max((pswInfo.slots?.length || 1) - 1, 0));
if (this._activeTab === 'psw') this._renderPswTab();
this._drawPreview();
}
}
});
const endDrag = () => {
dragInfo = null;
isPanning = false;
canvas.style.cursor = "grab";
};
canvas.addEventListener("mouseup", endDrag);
canvas.addEventListener("mouseleave", endDrag);
// ズーム(カーソル位置中心)
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
const cam = this._previewCam;
const oldZoom = cam.zoom;
const factor = 1.12;
cam.zoom = e.deltaY < 0
? Math.min(20.0, cam.zoom * factor)
: Math.max(0.05, cam.zoom / factor);
const rect = canvas.getBoundingClientRect();
const cvsX = (e.clientX - rect.left) * (canvas.width / rect.width);
const cvsY = (e.clientY - rect.top) * (canvas.height / rect.height);
const W = canvas.width, H = canvas.height;
cam.x += (cvsX - W / 2) * (1 / cam.zoom - 1 / oldZoom);
cam.y += (cvsY - H / 2) * (1 / cam.zoom - 1 / oldZoom);
this._drawPreview();
}, { passive: false });
canvas.addEventListener("contextmenu", e => e.preventDefault());
}
_placeSetupPoint(layerId, wx, wy, entry) {
if (!this.state.rigging[layerId]) {
this.state.rigging[layerId] = { mr_radius: 0 };
}
const rig = this.state.rigging[layerId];
const lx = Math.round(wx - entry.left);
const ly = Math.round(wy - entry.top);
if (this._setupPointType === 'r') {
if (!rig.r) rig.r = { x: 0, y: 0 };
rig.r.x = lx; rig.r.y = ly;
} else {
if (!rig.mr) rig.mr = { x: 0, y: 0 };
rig.mr.x = lx; rig.mr.y = ly;
if (rig.mr_radius === 0) { rig.mr_radius = 50; rig.mr_angle = 0; }
}
}
_findNodeById(id) {
const walk = nodes => {
for (const n of nodes) {
if (n.id === id) return n;
if (n.children) { const f = walk(n.children); if (f) return f; }
}
return null;
};
return walk(this.layerTree);
}
_renderTree() {
this._renderLayerTab();
this._renderParentTab();
this._drawPreview();
}
// レイヤータブ: 重ね順(cgOrder + CG構造)を表示。layerParent階層は示さない
_renderLayerTab() {
this._layerListEl.innerHTML = "";
// SWレイヤーを先頭に表示
for (const swLayer of this.state.swLayers) {
this._layerListEl.appendChild(this._mkSwLayerEl(swLayer));
}
// PSWレイヤーを表示
for (const pswLayer of this.state.pswLayers) {
this._layerListEl.appendChild(this._mkPswLayerEl(pswLayer));
}
const cgMap = {};
for (const cg of this.state.customGroups) cgMap[cg.id] = cg;
const itemParent = {};
for (const cg of this.state.customGroups) {
for (const lid of cg.layer_ids) itemParent[lid] = cg.id;
}
const groupedLayerIds = new Set();
for (const cg of this.state.customGroups) {
for (const lid of cg.layer_ids) { if (!cgMap[lid]) groupedLayerIds.add(lid); }
}
const walkPsdSubtree = (nodes, depth) => {
for (const n of [...nodes].reverse()) {
if (groupedLayerIds.has(n.id)) continue;
this._layerListEl.appendChild(this._mkLayerEl(n, depth, null));
if (n.kind === "group" && n.children && !this.collapsedIds.has(n.id)) walkPsdSubtree(n.children, depth + 1);
}
};
const renderCg = (cg, depth) => {
const parentCgId = itemParent[cg.id] || null;
this._layerListEl.appendChild(this._mkCustomGroupEl(cg, depth, parentCgId));
if (!this.collapsedIds.has(cg.id)) {
for (const lid of cg.layer_ids) {
if (cgMap[lid]) {
renderCg(cgMap[lid], depth + 1);
} else {
const node = this._findNodeById(lid);
if (node) this._layerListEl.appendChild(this._mkLayerEl(node, depth + 1, cg.id));
}
}
if (cg.layer_ids.length === 0) {
const empty = document.createElement("div");
empty.className = "psd-group-empty";
empty.textContent = t("dropLayerHere");
empty.style.paddingLeft = `${12 + (depth + 1) * 16}px`;
empty.addEventListener("dragover", e => { e.preventDefault(); empty.classList.add("drag-over"); });
empty.addEventListener("dragleave", () => empty.classList.remove("drag-over"));
empty.addEventListener("drop", e => {
e.preventDefault(); empty.classList.remove("drag-over");
if (this._dragSrcId) this._moveItemToGroup(this._dragSrcId, cg.id);
});
this._layerListEl.appendChild(empty);
}
}
};
const topPsdNodes = {};
for (const n of this.layerTree) topPsdNodes[n.id] = n;
const shown = new Set();
for (const id of this.state.cgOrder) {
if (cgMap[id] && !itemParent[id]) {
renderCg(cgMap[id], 0);
shown.add(id);
} else if (topPsdNodes[id] && !groupedLayerIds.has(id)) {
const n = topPsdNodes[id];
this._layerListEl.appendChild(this._mkLayerEl(n, 0, null));
if (n.kind === "group" && n.children && !this.collapsedIds.has(n.id)) walkPsdSubtree(n.children, 1);
shown.add(id);
}
}
for (const cg of this.state.customGroups) {
if (!itemParent[cg.id] && !shown.has(cg.id)) renderCg(cg, 0);
}
for (const n of [...this.layerTree].reverse()) {
if (!shown.has(n.id) && !groupedLayerIds.has(n.id)) {
this._layerListEl.appendChild(this._mkLayerEl(n, 0, null));
if (n.kind === "group" && n.children && !this.collapsedIds.has(n.id)) walkPsdSubtree(n.children, 1);
}
}
}
// ペアレントタブ: layerParent 親子関係をツリー表示。折りたたみ対応
_renderParentTab() {
this._parentListEl.innerHTML = "";
const rendered = new Set();
const renderRow = (layerId, depth) => {
if (rendered.has(layerId)) return;
rendered.add(layerId);
// PSDレイヤー or カスタムグループを解決
const psdNode = this._findNodeById(layerId);
const cgNode = psdNode ? null : this.state.customGroups.find(g => g.id === layerId);
if (!psdNode && !cgNode) return;
const isCg = !!cgNode;
const vis = isCg ? (cgNode.visible !== false) : (this.state.visibility[layerId] !== false);
const sel = isCg ? (this._selectedCgId === layerId) : this.selectedIds.has(layerId);
const children = this._ptGetChildren(layerId);
const collapsed = this.collapsedIds.has(layerId);
const row = document.createElement("div");
row.className = "psd-layer-item" + (sel ? " selected" : "") + (!vis ? " hidden-layer" : "");
row.dataset.id = layerId;
row.style.paddingLeft = `${12 + depth * 16}px`;
row.addEventListener("click", () => {
if (isCg) {
this.selectedIds.clear();
this._selectedCgId = (this._selectedCgId === layerId) ? null : layerId;
} else {
this._selectedCgId = null;
this.selectedIds.clear();
this.selectedIds.add(layerId);
}
this._renderTree();
});
const eye = document.createElement("span");
eye.className = "eye-btn";
eye.textContent = vis ? "👁" : "🚫";
eye.addEventListener("click", e => {
e.stopPropagation();
if (isCg) cgNode.visible = !vis; else this.state.visibility[layerId] = !vis;
this._renderTree();
});
row.appendChild(eye);
if (children.length > 0) {
const tog = document.createElement("span");
tog.className = "toggle-btn";
tog.textContent = collapsed ? "▶" : "▼";
tog.addEventListener("click", e => {
e.stopPropagation();
collapsed ? this.collapsedIds.delete(layerId) : this.collapsedIds.add(layerId);
this._renderTree();
});
row.appendChild(tog);
}
const icon = document.createElement("span");
icon.className = "kind-icon";
icon.textContent = isCg ? "📂" : (psdNode.kind === "group" ? "📁" : "🖼");
row.appendChild(icon);
const nameEl = document.createElement("span");
nameEl.className = "layer-name";
nameEl.textContent = isCg
? `[CG] ${cgNode.name}`
: (this.state.renamed[layerId] ?? psdNode.name);
if (!isCg) nameEl.addEventListener("dblclick", e => { e.stopPropagation(); this._inlineRename(nameEl, layerId); });
row.appendChild(nameEl);
this._parentListEl.appendChild(row);
if (children.length > 0 && !collapsed) {
for (const childId of children) renderRow(childId, depth + 1);
}
};
for (const id of this._ptGetRoots()) renderRow(id, 0);
}
_mkLayerEl(node, depth, groupId) {
const vis = this.state.visibility[node.id] !== false;
const sel = this.selectedIds.has(node.id);
const row = document.createElement("div");
row.className = "psd-layer-item"
+ (sel ? " selected" : "")
+ (!vis ? " hidden-layer" : "")
+ (groupId ? " in-custom-group" : "");
row.dataset.id = node.id;
if (groupId) row.dataset.groupId = groupId;
row.draggable = true;
row.style.paddingLeft = `${12 + depth * 16}px`;
row.addEventListener("dragstart", e => {
this._dragSrcId = node.id; this._dragSrcCgId = groupId;
e.dataTransfer.effectAllowed = "move";
});
row.addEventListener("dragover", e => { e.preventDefault(); row.classList.add("drag-over"); });
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
row.addEventListener("drop", e => {
e.preventDefault(); row.classList.remove("drag-over");
const srcId = this._dragSrcId, srcCgId = this._dragSrcCgId;
this._dragSrcId = null; this._dragSrcCgId = null;
if (!srcId || srcId === node.id) return;
const tgtCgId = row.dataset.groupId || null;
if (tgtCgId) this._moveItemToGroup(srcId, tgtCgId);
else if (srcCgId) { this._removeItemFromGroup(srcId, srcCgId); this._reorder(srcId, node.id); }
else this._reorder(srcId, node.id);
});
let singleClickTimer = null;
row.addEventListener("click", e => {
if (e.target === nameEl) {
// nameEl クリックは dblclick のために遅延させる
clearTimeout(singleClickTimer);
singleClickTimer = setTimeout(() => {
singleClickTimer = null;
this._selectedCgId = null; this._selectedSwLayerId = null;
const curSel = this.selectedIds.has(node.id);
if (e.ctrlKey || e.metaKey) { curSel ? this.selectedIds.delete(node.id) : this.selectedIds.add(node.id); }
else { this.selectedIds.clear(); this.selectedIds.add(node.id); }
this._renderTree();
}, 250);
} else {
clearTimeout(singleClickTimer);
singleClickTimer = null;
this._selectedCgId = null; this._selectedSwLayerId = null;
if (e.ctrlKey || e.metaKey) { sel ? this.selectedIds.delete(node.id) : this.selectedIds.add(node.id); }
else { this.selectedIds.clear(); this.selectedIds.add(node.id); }
this._renderTree();
}
});
const handle = document.createElement("span"); handle.className = "handle"; handle.textContent = "≡"; row.appendChild(handle);
const eye = document.createElement("span"); eye.className = "eye-btn"; eye.textContent = vis ? "👁" : "🚫";
eye.addEventListener("click", e => { e.stopPropagation(); this.state.visibility[node.id] = !vis; this._renderTree(); });
row.appendChild(eye);
if (node.kind === "group") {
const tog = document.createElement("span"); tog.className = "toggle-btn";
tog.textContent = this.collapsedIds.has(node.id) ? "▶" : "▼";
tog.addEventListener("click", e => { e.stopPropagation(); this.collapsedIds.has(node.id) ? this.collapsedIds.delete(node.id) : this.collapsedIds.add(node.id); this._renderTree(); });
row.appendChild(tog);
}
const icon = document.createElement("span"); icon.className = "kind-icon"; icon.textContent = node.kind === "group" ? "📁" : "🖼"; row.appendChild(icon);
if (node.clipping) {
const clipBadge = document.createElement("span");
clipBadge.textContent = "✂";
clipBadge.title = t("clippingLayerBadge");
clipBadge.style.cssText = "font-size:9px;color:#f9e2af;opacity:0.8;margin-right:2px;cursor:default;";
row.appendChild(clipBadge);
}
const nameEl = document.createElement("span"); nameEl.className = "layer-name";
nameEl.textContent = this.state.renamed[node.id] ?? node.name;
nameEl.title = t("dblClickToRename");
nameEl.addEventListener("dblclick", e => { e.stopPropagation(); clearTimeout(singleClickTimer); singleClickTimer = null; this._inlineRename(nameEl, node.id); });
row.appendChild(nameEl);
return row;
}
_mkCustomGroupEl(cg, depth = 0, parentCgId = null) {
const vis = cg.visible !== false;
const collapsed = this.collapsedIds.has(cg.id);
const isSel = this._selectedCgId === cg.id;
const row = document.createElement("div");
row.className = "psd-custom-group"
+ (!vis ? " hidden-layer" : "")
+ (isSel ? " selected" : "")
+ (parentCgId ? " in-custom-group" : "");
row.dataset.cgId = cg.id;
if (parentCgId) row.dataset.groupId = parentCgId;
row.style.paddingLeft = `${12 + depth * 16}px`;
row.draggable = true;
row.addEventListener("dragstart", e => {
this._dragSrcId = cg.id; this._dragSrcCgId = parentCgId;
e.dataTransfer.effectAllowed = "move";
e.stopPropagation();
});
row.addEventListener("dragover", e => { e.preventDefault(); e.stopPropagation(); row.classList.add("drag-over"); });
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
row.addEventListener("drop", e => {
e.preventDefault(); e.stopPropagation(); row.classList.remove("drag-over");
const srcId = this._dragSrcId;
if (!srcId || srcId === cg.id) return;
this._moveItemToGroup(srcId, cg.id);
});
let cgClickTimer = null;
row.addEventListener("click", e => {
if (["eye-btn","toggle-btn","handle"].some(c => e.target.classList.contains(c))) return;
if (e.target === nameEl) {
clearTimeout(cgClickTimer);
cgClickTimer = setTimeout(() => {
cgClickTimer = null;
this.selectedIds.clear(); this._selectedCgId = isSel ? null : cg.id; this._selectedSwLayerId = null; this._renderTree();
}, 250);
} else {
clearTimeout(cgClickTimer); cgClickTimer = null;
this.selectedIds.clear(); this._selectedCgId = isSel ? null : cg.id; this._selectedSwLayerId = null; this._renderTree();
}
});
const handle = document.createElement("span"); handle.className = "handle"; handle.textContent = "≡"; row.appendChild(handle);
const eye = document.createElement("span"); eye.className = "eye-btn"; eye.textContent = vis ? "👁" : "🚫";
eye.addEventListener("click", e => { e.stopPropagation(); cg.visible = !vis; this._renderTree(); });
row.appendChild(eye);
const tog = document.createElement("span"); tog.className = "toggle-btn"; tog.textContent = collapsed ? "▶" : "▼";
tog.addEventListener("click", e => { e.stopPropagation(); collapsed ? this.collapsedIds.delete(cg.id) : this.collapsedIds.add(cg.id); this._renderTree(); });
row.appendChild(tog);
const icon = document.createElement("span"); icon.textContent = "📂"; icon.style.flexShrink = "0"; row.appendChild(icon);
const nameEl = document.createElement("span"); nameEl.className = "cg-name";
nameEl.textContent = `${t("customPrefix")} ${cg.name}`; nameEl.title = t("dblClickToRename");
nameEl.addEventListener("dblclick", e => {
e.stopPropagation();
clearTimeout(cgClickTimer); cgClickTimer = null;
const input = document.createElement("input");
input.value = cg.name;
input.style.cssText = "width:120px;background:#313244;border:1px solid #89b4fa;border-radius:3px;color:#f38ba8;padding:1px 6px;font-size:12px;outline:none;";
nameEl.replaceWith(input); input.focus(); input.select();
const commit = () => { if (!input.isConnected) return; const v = input.value.trim(); if (v) cg.name = v; nameEl.textContent = `${t("customPrefix")} ${cg.name}`; input.replaceWith(nameEl); };
input.addEventListener("blur", commit);
input.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); commit(); } if (e.key === "Escape") input.replaceWith(nameEl); });
});
row.appendChild(nameEl);
const badge = document.createElement("span"); badge.className = "cg-badge"; badge.textContent = cg.layer_ids.length; row.appendChild(badge);
return row;
}
_mkSwLayerEl(swLayer) {
const isSel = this._selectedSwLayerId === swLayer.id;
const row = document.createElement("div");
row.className = "psd-custom-group" + (isSel ? " selected" : "");
row.style.cssText = `padding-left:12px;background:${isSel ? "#0d1a2e" : "#0a1020"};border-left:3px solid #2266cc;cursor:pointer;`;
let clickTimer = null;
const doSelect = () => {
const nowSel = this._selectedSwLayerId === swLayer.id;
this.selectedIds.clear(); this._selectedCgId = null;
this._selectedPswLayerId = null;
this._selectedSwLayerId = nowSel ? null : swLayer.id;
this._renderTree();
if (this._activeTab === 'switch') this._renderSwitchTab();
};
row.addEventListener("click", e => {
if (e.target.classList.contains("eye-btn")) return;
clearTimeout(clickTimer);
if (e.target === nameEl) {
clickTimer = setTimeout(() => { clickTimer = null; doSelect(); }, 250);
} else {
doSelect();
}
});
const icon = document.createElement("span"); icon.textContent = "🔀"; icon.style.flexShrink = "0";
const nameEl = document.createElement("span"); nameEl.className = "cg-name";
nameEl.textContent = `${t("swPrefix")} ${swLayer.name}`;
nameEl.style.color = "#55aaff";
nameEl.title = t("dblClickToRename");
nameEl.addEventListener("dblclick", e => {
e.stopPropagation();
clearTimeout(clickTimer); clickTimer = null;
const input = document.createElement("input");
input.value = swLayer.name;
input.style.cssText = "width:120px;background:#313244;border:1px solid #2266cc;border-radius:3px;color:#55aaff;padding:1px 6px;font-size:12px;outline:none;";
nameEl.replaceWith(input); input.focus(); input.select();
const commit = () => {
if (!input.isConnected) return;
const v = input.value.trim(); if (v) swLayer.name = v;
nameEl.textContent = `${t("swPrefix")} ${swLayer.name}`; input.replaceWith(nameEl);
};
input.addEventListener("blur", commit);
input.addEventListener("keydown", ev => { if (ev.key === "Enter") { ev.preventDefault(); commit(); } if (ev.key === "Escape") input.replaceWith(nameEl); });
});
const badge = document.createElement("span"); badge.className = "cg-badge"; badge.textContent = swLayer.points.length;
row.append(icon, nameEl, badge);
return row;
}
_createSwLayer() {
let idx = 1;
const used = new Set(this.state.swLayers.map(l => l.name));
while (used.has(`SW${idx}`)) idx++;
const swLayer = { id: `swl_${Math.random().toString(36).slice(2)}`, name: `SW${idx}`, points: [] };
this.state.swLayers.push(swLayer);
this._selectedSwLayerId = swLayer.id;
this._renderTree();
if (this._activeTab === 'switch') this._renderSwitchTab();
}
_deleteSwLayer() {
const id = this._selectedSwLayerId;
if (!id) return;
this.state.swLayers = this.state.swLayers.filter(l => l.id !== id);
this._selectedSwLayerId = null;
this._selectedSwPointInfo = null;
this._renderTree();
if (this._activeTab === 'switch') this._renderSwitchTab();
this._drawPreview();
}
// ---- PSW(ポーズスイッチ)関連メソッド ----
_nextPswPointName() {
let idx = 1;
const used = new Set();
for (const l of this.state.pswLayers) for (const p of l.points) used.add(p.name);
while (used.has(`PSW${idx}`)) idx++;
return `PSW${idx}`;
}
_placePswPoint(wx, wy) {
const pswLayer = this.state.pswLayers.find(l => l.id === this._selectedPswLayerId);
if (!pswLayer) return;
const pt = {
id: `pswp_${Math.random().toString(36).slice(2)}`,
name: this._nextPswPointName(),
x: Math.round(wx),
y: Math.round(wy),
radius: 80,
angle: 0,
slots: [{ degree: 0, pose: null }],
};
pswLayer.points.push(pt);
this._selectedPswPointInfo = { pswLayerId: pswLayer.id, pointId: pt.id };
this._selectedPswSlotIdx = 0;
this._pswEditingPointId = null;
this._setupPointType = null;
this._updateSetupBtns();
if (this._activeTab === 'psw') this._renderPswTab();
this._renderLayerTab();
this._drawPreview();
}
_createPswLayer() {
let idx = 1;
const used = new Set(this.state.pswLayers.map(l => l.name));
while (used.has(`PSW${idx}`)) idx++;
const pswLayer = { id: `pswl_${Math.random().toString(36).slice(2)}`, name: `PSW${idx}`, points: [] };
this.state.pswLayers.push(pswLayer);
this._selectedPswLayerId = pswLayer.id;
this._renderTree();
if (this._activeTab === 'psw') this._renderPswTab();
}
_deletePswLayer() {
const id = this._selectedPswLayerId;
if (!id) return;
this.state.pswLayers = this.state.pswLayers.filter(l => l.id !== id);
this._selectedPswLayerId = null;
this._selectedPswPointInfo = null;
this._pswEditingPointId = null;
this._renderTree();
if (this._activeTab === 'psw') this._renderPswTab();
}
_addPswSlot() {
const info = this._selectedPswPointInfo;
if (!info) return;
const pswLayer = this.state.pswLayers.find(l => l.id === info.pswLayerId);
const pt = pswLayer?.points?.find(p => p.id === info.pointId);
if (!pt) return;
if (pt.slots.length >= 12) { alert(t("maxPswSlotsReached")); return; }
const nextDeg = pt.slots.length * 30;
pt.slots.push({ degree: nextDeg, pose: null });
this._renderPswTab();
this._drawPreview();
}
_removePswSlot() {
const info = this._selectedPswPointInfo;
if (!info) return;
const pswLayer = this.state.pswLayers.find(l => l.id === info.pswLayerId);
const pt = pswLayer?.points?.find(p => p.id === info.pointId);
if (!pt || pt.slots.length <= 1) return;
pt.slots.pop();
this._selectedPswSlotIdx = Math.min(this._selectedPswSlotIdx, pt.slots.length - 1);
this._renderPswTab();
this._drawPreview();
}
_setMlpPose() {
const info = this._selectedPswPointInfo;
if (!info) return;
const pswLayer = this.state.pswLayers.find(l => l.id === info.pswLayerId);
const pt = pswLayer?.points?.find(p => p.id === info.pointId);
if (!pt) return;
const slotIdx = this._selectedPswSlotIdx;
if (slotIdx < 0 || slotIdx >= pt.slots.length) return;
if (slotIdx !== 0) return; // 0°スロット専用
pt.slots[slotIdx].pose = JSON.parse(JSON.stringify(this.state.pose));
this._pswEditingPointId = null;
this._renderPswTab();
this._drawPreview();
}
_setSlpPose() {
const info = this._selectedPswPointInfo;
if (!info) return;
const pswLayer = this.state.pswLayers.find(l => l.id === info.pswLayerId);
const pt = pswLayer?.points?.find(p => p.id === info.pointId);
if (!pt) return;
const slotIdx = this._selectedPswSlotIdx;
if (slotIdx < 0 || slotIdx >= pt.slots.length) return;
if (slotIdx !== 0) return; // 0°スロット専用
// レイヤー選択があれば記憶。なければ直前のSLPレイヤー→0°スロットの順にフォールバック
const selId = [...this.selectedIds][0];
if (selId) this._lastSlpLayerId = selId;
if (!this._lastSlpLayerId) {
const s0pose = pt.slots[0]?.pose;
if (s0pose) {
const ids = Object.keys(s0pose);
if (ids.length === 1) this._lastSlpLayerId = ids[0];
}
}
const layerId = this._lastSlpLayerId;
if (!layerId) { alert(t("noLayerSelectedForSlp")); return; }
const existing = pt.slots[slotIdx].pose ? JSON.parse(JSON.stringify(pt.slots[slotIdx].pose)) : {};
existing[layerId] = JSON.parse(JSON.stringify(this.state.pose[layerId] ?? { angle: 0, tx: 0, ty: 0 }));
pt.slots[slotIdx].pose = existing;
this._pswEditingPointId = null;
this._renderPswTab();
this._drawPreview();
}
_clearPswPose() {
const info = this._selectedPswPointInfo;
if (!info) return;
const pswLayer = this.state.pswLayers.find(l => l.id === info.pswLayerId);
const pt = pswLayer?.points?.find(p => p.id === info.pointId);
if (!pt) return;
const slotIdx = this._selectedPswSlotIdx;
if (slotIdx < 0 || slotIdx >= pt.slots.length) return;
pt.slots[slotIdx].pose = null;
this._renderPswTab();
this._drawPreview();
}
_renderPswTab() {
if (!this._pswListEl || !this._pswSlotListEl) return;
this._pswListEl.innerHTML = "";
for (const pswLayer of this.state.pswLayers) {
// PSWレイヤーヘッダー
const layerRow = document.createElement("div");
layerRow.style.cssText = "padding:3px 8px;font-size:11px;color:#ff7766;background:#1e0a0a;border-left:3px solid #cc3344;display:flex;align-items:center;gap:4px;cursor:pointer;";
const isLayerSel = this._selectedPswLayerId === pswLayer.id;
if (isLayerSel) layerRow.style.background = "#2a1a3a";
const layerNameEl = document.createElement("span");
layerNameEl.textContent = `[PSW] ${pswLayer.name}`;
layerNameEl.style.flex = "1";
layerNameEl.title = t("dblClickToRename");
let layerClickTimer = null;
layerRow.addEventListener("click", () => {
if (layerClickTimer) return;
layerClickTimer = setTimeout(() => {
layerClickTimer = null;
this._selectedPswLayerId = isLayerSel ? null : pswLayer.id;
this._renderPswTab();
}, 260);
});
layerNameEl.addEventListener("dblclick", e => {
e.stopPropagation();
clearTimeout(layerClickTimer); layerClickTimer = null;
const input = document.createElement("input");
input.value = pswLayer.name;
input.style.cssText = "flex:1;background:#313244;border:1px solid #cc3344;border-radius:3px;color:#cdd6f4;padding:1px 6px;font-size:11px;outline:none;";
layerNameEl.replaceWith(input); input.focus(); input.select();
const commit = () => {
if (!input.isConnected) return;
const v = input.value.trim(); if (v) pswLayer.name = v;
layerNameEl.textContent = `[PSW] ${pswLayer.name}`;
input.replaceWith(layerNameEl);
this._renderLayerTab();
};
input.addEventListener("blur", commit);
input.addEventListener("keydown", ev => { if (ev.key === "Enter") { ev.preventDefault(); commit(); } if (ev.key === "Escape") input.replaceWith(layerNameEl); });
});
layerRow.appendChild(layerNameEl);
this._pswListEl.appendChild(layerRow);
for (const pt of pswLayer.points) {
const isPtSel = this._selectedPswPointInfo?.pswLayerId === pswLayer.id
&& this._selectedPswPointInfo?.pointId === pt.id;
const row = document.createElement("div");
row.className = "psd-layer-item" + (isPtSel ? " selected" : "");
row.style.cssText = "padding:3px 10px 3px 20px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:4px;";
const nameEl = document.createElement("span");
nameEl.className = "layer-name";
nameEl.textContent = pt.name;
nameEl.title = t("clickSelectDblRename");
let clickTimer = null;
nameEl.addEventListener("click", e => {
e.stopPropagation();
if (clickTimer) return;
clickTimer = setTimeout(() => {
clickTimer = null;
this._selectedPswPointInfo = isPtSel ? null : { pswLayerId: pswLayer.id, pointId: pt.id };
if (!isPtSel) { this._selectedPswSlotIdx = 0; this._pswEditingPointId = null; }
this._renderPswTab();
this._drawPreview();
}, 250);
});
nameEl.addEventListener("dblclick", e => {
e.stopPropagation();
clearTimeout(clickTimer); clickTimer = null;
this._selectedPswPointInfo = { pswLayerId: pswLayer.id, pointId: pt.id };
const input = document.createElement("input");
input.value = pt.name;
input.style.cssText = "width:90px;background:#313244;border:1px solid #cc88ff;border-radius:3px;color:#cdd6f4;padding:1px 6px;font-size:12px;outline:none;";
nameEl.replaceWith(input); input.focus(); input.select();
const commit = () => {
if (!input.isConnected) return;
const v = input.value.trim(); if (v) pt.name = v;
nameEl.textContent = pt.name; input.replaceWith(nameEl); this._drawPreview();
};
input.addEventListener("blur", commit);
input.addEventListener("keydown", ev => { if (ev.key === "Enter") commit(); if (ev.key === "Escape") input.replaceWith(nameEl); });
});
const delBtn = document.createElement("button");
delBtn.className = "psd-btn";
delBtn.textContent = "✕";
delBtn.style.cssText = "padding:1px 5px;font-size:10px;margin-left:auto;flex-shrink:0;";
delBtn.title = t("deletePswPointTooltip");
delBtn.onclick = e => {
e.stopPropagation();
pswLayer.points = pswLayer.points.filter(p => p.id !== pt.id);
if (isPtSel) { this._selectedPswPointInfo = null; this._pswEditingPointId = null; }
this._renderPswTab();
this._drawPreview();
};
row.append(nameEl, delBtn);
row.addEventListener("click", e => {
if (e.target === nameEl) return;
this._selectedPswPointInfo = isPtSel ? null : { pswLayerId: pswLayer.id, pointId: pt.id };
if (!isPtSel) { this._selectedPswSlotIdx = 0; this._pswEditingPointId = null; }
this._renderPswTab();
this._drawPreview();
});
this._pswListEl.appendChild(row);
}
}
// スロットリスト(選択中PSWポイントのスロット表示)
this._pswSlotListEl.innerHTML = "";
const selInfo = this._selectedPswPointInfo;
if (selInfo) {
const pswLayer = this.state.pswLayers.find(l => l.id === selInfo.pswLayerId);
const pt = pswLayer?.points?.find(p => p.id === selInfo.pointId);
if (pt) {
const PSW_STEP = Math.PI / 6;
const n = pt.slots.length;
const range = PSW_STEP * n;
const normAngle = ((pt.angle % range) + range) % range;
const activeSlotIdx = Math.min(Math.floor(normAngle / PSW_STEP), n - 1);
const hdr = document.createElement("div");
hdr.style.cssText = "font-size:10px;color:#888;padding:4px 0 2px;";
hdr.textContent = `${pt.name} — スロット (ハンドル: ${activeSlotIdx}番アクティブ)`;
this._pswSlotListEl.appendChild(hdr);
for (let i = 0; i < pt.slots.length; i++) {
const slot = pt.slots[i];
const isSlotSel = this._selectedPswSlotIdx === i;
const isActive = activeSlotIdx === i;
const hasPose = !!slot.pose;
const isEditing = isSlotSel && this._pswEditingPointId === pt.id;
const slotRow = document.createElement("div");
slotRow.style.cssText = `
padding:3px 8px;font-size:11px;cursor:pointer;
display:flex;align-items:center;gap:6px;
background:${isSlotSel ? "#2a0d10" : isActive ? "#1a2a1a" : ""};
border-left:3px solid ${isSlotSel ? "#cc3344" : isActive ? "#44cc44" : "transparent"};
`;
const degLabel = document.createElement("span");
degLabel.textContent = `${i * 30}°`;
degLabel.style.cssText = "font-weight:bold;color:#ff7766;min-width:36px;";
const poseLabel = document.createElement("span");
poseLabel.style.cssText = `flex:1;color:${hasPose ? "#a6e3a1" : "#555"};`;
if (isEditing) {
poseLabel.textContent = "✏ 編集中...";
} else if (hasPose) {
const layerIds = Object.keys(slot.pose);
if (layerIds.length === 1 && slot.degree === 0) {
const lid = layerIds[0];
const node = this._findNodeById(lid);
const lname = (this.state.renamed?.[lid] ?? node?.name) || lid;
poseLabel.textContent = `✓ : ${lname}`;
} else if (layerIds.length === 1) {
poseLabel.textContent = `✓`;
} else {
poseLabel.textContent = `✓ (${layerIds.length}レイヤー)`;
}
} else {
poseLabel.textContent = "−";
}
const editBtn = document.createElement("button");
editBtn.className = "psd-btn";
editBtn.textContent = isEditing ? "✓確定" : "✏編集";
editBtn.style.cssText = "padding:1px 5px;font-size:10px;flex-shrink:0;";
editBtn.onclick = e => {
e.stopPropagation();
this._selectedPswSlotIdx = i;
if (this._pswEditingPointId === pt.id) {
// 確定: slot0のレイヤーリスト(or既存スロットのリスト)で現在のposeを保存
const refPose = slot.pose || pt.slots[0]?.pose;
if (refPose) {
const newPose = {};
for (const lid of Object.keys(refPose)) {
newPose[lid] = JSON.parse(JSON.stringify(this.state.pose[lid] ?? { angle: 0, tx: 0, ty: 0 }));
}
slot.pose = newPose;
}
this._pswEditingPointId = null;
} else {
// 編集開始:スロットのポーズをstate.poseにロード
this._pswEditingPointId = pt.id;
if (slot.pose) {
// スロットに登録済みポーズがある場合は読み込む
Object.assign(this.state.pose, JSON.parse(JSON.stringify(slot.pose)));
} else if (i > 0 && pt.slots[0]?.pose) {
// 未登録スロットは slot0 のポーズを初期値としてロード
Object.assign(this.state.pose, JSON.parse(JSON.stringify(pt.slots[0].pose)));
}
// PSWハンドルをこのスロットの角度に移動
pt.angle = PSW_STEP * i;
}
this._renderPswTab();
this._drawPreview();
};
slotRow.onclick = () => {
this._selectedPswSlotIdx = i;
this._pswEditingPointId = null; // 編集モード終了
pt.angle = PSW_STEP * i;
this._renderPswTab();
this._drawPreview();
};
slotRow.append(degLabel, poseLabel, editBtn);
this._pswSlotListEl.appendChild(slotRow);
}
}
}
}
_mkPswLayerEl(pswLayer) {
const isLayerSel = this._selectedPswLayerId === pswLayer.id;
const row = document.createElement("div");
row.className = "psd-custom-group" + (isLayerSel ? " selected" : "");
row.style.cssText = `padding:3px 8px;display:flex;align-items:center;gap:4px;cursor:pointer;background:${isLayerSel ? "#2a0d10" : "#1e0a0a"};border-left:3px solid #cc3344;`;
const icon = document.createElement("span");
icon.textContent = "🔀";
icon.style.flexShrink = "0";
const nameEl = document.createElement("span");
nameEl.className = "cg-name";
nameEl.textContent = `[PSW] ${pswLayer.name}`;
nameEl.style.color = "#ff7766";
nameEl.title = t("dblClickToRename");
let clickTimer = null;
nameEl.addEventListener("click", e => {
e.stopPropagation();
if (clickTimer) return;
clickTimer = setTimeout(() => {
clickTimer = null;
this.selectedIds.clear(); this._selectedCgId = null;
this._selectedSwLayerId = null;
this._selectedPswLayerId = isLayerSel ? null : pswLayer.id;
this._renderLayerTab();
}, 260);
});
nameEl.addEventListener("dblclick", e => {
e.stopPropagation();
clearTimeout(clickTimer); clickTimer = null;
const input = document.createElement("input");
input.value = pswLayer.name;
input.style.cssText = "width:120px;background:#313244;border:1px solid #cc3344;border-radius:3px;color:#ff7766;padding:1px 6px;font-size:12px;outline:none;";
nameEl.replaceWith(input); input.focus(); input.select();
const commit = () => {
if (!input.isConnected) return;
const v = input.value.trim(); if (v) pswLayer.name = v;
nameEl.textContent = `[PSW] ${pswLayer.name}`;
input.replaceWith(nameEl);
if (this._activeTab === 'psw') this._renderPswTab();
};
input.addEventListener("blur", commit);
input.addEventListener("keydown", ev => { if (ev.key === "Enter") { ev.preventDefault(); commit(); } if (ev.key === "Escape") input.replaceWith(nameEl); });
});
row.addEventListener("click", e => {
if (e.target === nameEl) return;
this.selectedIds.clear(); this._selectedCgId = null;
this._selectedSwLayerId = null;
this._selectedPswLayerId = isLayerSel ? null : pswLayer.id;
this._renderLayerTab();
});
const badge = document.createElement("span"); badge.className = "cg-badge"; badge.textContent = pswLayer.points.length;
row.append(icon, nameEl, badge);
return row;
}
_moveItemToGroup(itemId, groupId) {
if (itemId.startsWith('cg_')) {
const desc = new Set();
const collectDesc = id => {
desc.add(id);
const cg = this.state.customGroups.find(g => g.id === id);
if (cg) for (const lid of cg.layer_ids) if (lid.startsWith('cg_')) collectDesc(lid);
};
collectDesc(itemId);
if (desc.has(groupId)) return;
// ルートCGが子CGになる場合、cgOrderから削除
const oi = this.state.cgOrder.indexOf(itemId);
if (oi !== -1) this.state.cgOrder.splice(oi, 1);
}
// 元のグループから削除 → layer_parent をクリア(元グループのIDだった場合のみ)
for (const g of this.state.customGroups) {
if (g.layer_ids.includes(itemId) && !itemId.startsWith('cg_')) {
if (this.state.layerParent[itemId] === g.id) delete this.state.layerParent[itemId];
}
g.layer_ids = g.layer_ids.filter(id => id !== itemId);
}
const tgt = this.state.customGroups.find(g => g.id === groupId);
if (tgt && !tgt.layer_ids.includes(itemId)) {
tgt.layer_ids.push(itemId);
// グループのリグ伝播のため layer_parent を自動設定(未設定のもののみ)
if (!itemId.startsWith('cg_') && !this.state.layerParent[itemId]) {
this.state.layerParent[itemId] = groupId;
}
}
this._dragSrcId = null; this._dragSrcCgId = null;
this._renderTree();
}
_removeItemFromGroup(itemId, groupId) {
const g = this.state.customGroups.find(g => g.id === groupId);
if (g) g.layer_ids = g.layer_ids.filter(id => id !== itemId);
// layer_parent をクリア(このグループが親だった場合のみ)
if (!itemId.startsWith('cg_') && this.state.layerParent[itemId] === groupId) {
delete this.state.layerParent[itemId];
}
// CGがルートに戻る場合、cgOrderに追加
if (itemId.startsWith('cg_') && !this.state.cgOrder.includes(itemId)) {
this.state.cgOrder.unshift(itemId);
}
}
_inlineRename(nameEl, nodeId) {
const orig = nameEl.textContent;
const input = document.createElement("input"); input.value = orig;
nameEl.replaceWith(input); input.focus(); input.select();
const commit = () => {
if (!input.isConnected) return;
const v = input.value.trim();
if (v && v !== orig) this.state.renamed[nodeId] = v;
nameEl.textContent = this.state.renamed[nodeId] ?? orig;
input.replaceWith(nameEl);
};
input.addEventListener("blur", commit);
input.addEventListener("keydown", e => { if (e.key === "Enter") commit(); if (e.key === "Escape") input.replaceWith(nameEl); });
}
_reorder(srcId, tgtId) {
// トップレベル(cgOrder)の並べ替え
const cgOrder = this.state.cgOrder;
const si = cgOrder.indexOf(srcId);
const ti = cgOrder.indexOf(tgtId);
if (si !== -1 && ti !== -1) {
cgOrder.splice(si, 1);
cgOrder.splice(cgOrder.indexOf(tgtId), 0, srcId);
this._renderTree();
return;
}
// PSDサブツリー内の並べ替え
const move = nodes => {
const si = nodes.findIndex(n => n.id === srcId), ti = nodes.findIndex(n => n.id === tgtId);
if (si !== -1 && ti !== -1) { const [item] = nodes.splice(si, 1); nodes.splice(ti, 0, item); return true; }
for (const n of nodes) { if (n.children && move(n.children)) return true; }
return false;
};
move(this.layerTree);
this._renderTree();
}
_createGroup() {
const name = prompt(t("promptGroupName"), t("promptGroupDefault", this.state.customGroups.length + 1));
if (!name) return;
const cgId = `cg_${Date.now()}`;
const layer_ids = this.selectedIds.size > 0 ? [...this.selectedIds] : [];
for (const g of this.state.customGroups) g.layer_ids = g.layer_ids.filter(id => !layer_ids.includes(id));
const newCg = { id: cgId, name, visible: true, layer_ids };
this.state.customGroups.push(newCg);
// グループ内レイヤーの layer_parent を自動設定(連動制御のため)
for (const lid of layer_ids) {
if (!this.state.layerParent[lid]) {
this.state.layerParent[lid] = cgId;
}
}
if (this._selectedCgId) {
// 選択中CGのサブグループとして追加(cgOrderには追加しない)
const parentCg = this.state.customGroups.find(g => g.id === this._selectedCgId);
if (parentCg) parentCg.layer_ids.push(cgId);
} else {
// ルートCGとして cgOrder の先頭に追加
this.state.cgOrder.unshift(cgId);
}
this.selectedIds.clear();
this._selectedCgId = cgId;
this._renderTree();
}
_ungroup() {
if (!this.state.customGroups.length) { alert(t("noCustomGroupToRemove")); return; }
let targetId = this._selectedCgId;
if (!targetId) {
const allIds = new Set(this.state.customGroups.map(g => g.id));
const childIds = new Set();
for (const cg of this.state.customGroups) {
for (const lid of cg.layer_ids) if (allIds.has(lid)) childIds.add(lid);
}
const rootCgs = this.state.customGroups.filter(cg => !childIds.has(cg.id));
if (!rootCgs.length) return;
targetId = rootCgs[rootCgs.length - 1].id;
}
const idx = this.state.customGroups.findIndex(g => g.id === targetId);
if (idx === -1) return;
const targetCg = this.state.customGroups[idx];
const cgOrderIdx = this.state.cgOrder.indexOf(targetId);
// 子CG をターゲットの位置に挿入(ルートCGになる)
const childCgIds = targetCg.layer_ids.filter(lid => lid.startsWith('cg_'));
for (const childId of [...childCgIds].reverse()) {
if (!this.state.cgOrder.includes(childId)) {
const insertAt = cgOrderIdx !== -1 ? cgOrderIdx : 0;
this.state.cgOrder.splice(insertAt, 0, childId);
}
}
// cgOrder からターゲットを削除
const oi = this.state.cgOrder.indexOf(targetId);
if (oi !== -1) this.state.cgOrder.splice(oi, 1);
// 親グループの layer_ids からも削除
for (const g of this.state.customGroups) g.layer_ids = g.layer_ids.filter(id => id !== targetId);
// グループのリグポイントを削除し、layer_parent をクリア
delete this.state.rigging[targetId];
delete this.state.pose[targetId];
for (const lid of targetCg.layer_ids) {
if (this.state.layerParent[lid] === targetId) delete this.state.layerParent[lid];
}
this.state.customGroups.splice(idx, 1);
this._selectedCgId = null;
this._renderTree();
}
_indent() {
const selId = this._selectedCgId || [...this.selectedIds][0];
if (!selId) return;
if (this._activeTab === 'parent') { this._indentParent(selId); return; }
// レイヤータブ: cgOrder / CG内の直前アイテムを親にする
const cgMap = {};
for (const cg of this.state.customGroups) cgMap[cg.id] = cg;
const itemParent = {};
for (const cg of this.state.customGroups) {
for (const lid of cg.layer_ids) itemParent[lid] = cg.id;
}
const lp = this.state.layerParent;
const parentCgId = itemParent[selId];
let prevId;
if (!parentCgId) {
const idx = this.state.cgOrder.indexOf(selId);
if (idx <= 0) return;
prevId = this.state.cgOrder[idx - 1];
} else {
const parentCg = cgMap[parentCgId];
if (!parentCg) return;
const displayOrder = [];
const addWithChildren = lid => {
displayOrder.push(lid);
parentCg.layer_ids.filter(c => lp[c] === lid && !cgMap[c]).forEach(addWithChildren);
};
parentCg.layer_ids
.filter(lid => !parentCg.layer_ids.includes(lp[lid]) || cgMap[lid])
.forEach(lid => cgMap[lid] ? displayOrder.push(lid) : addWithChildren(lid));
const idx = displayOrder.indexOf(selId);
if (idx <= 0) return;
prevId = displayOrder[idx - 1];
}
if (!prevId || cgMap[prevId]) return;
if (lp[selId] === prevId) return;
lp[selId] = prevId;
this._renderTree();
}
_indentParent(selId) {
const lp = this.state.layerParent;
const parentId = lp[selId] || null;
const siblings = parentId === null ? this._ptGetRoots() : this._ptGetChildren(parentId);
const idx = siblings.indexOf(selId);
if (idx <= 0) return;
const prevId = siblings[idx - 1];
if (!prevId || lp[selId] === prevId) return;
// parentTabOrder を更新: 旧親から取り出し、新親の子に追加
if (parentId === null) {
const roots = this._ptGetRoots();
const ri = roots.indexOf(selId);
if (ri !== -1) roots.splice(ri, 1);
this.state.parentTabOrder.roots = roots;
} else {
const ch = this._ptGetChildren(parentId);
const ci = ch.indexOf(selId);
if (ci !== -1) ch.splice(ci, 1);
this.state.parentTabOrder.children[parentId] = ch;
}
const newCh = this._ptGetChildren(prevId);
if (!newCh.includes(selId)) newCh.push(selId);
this.state.parentTabOrder.children[prevId] = newCh;
lp[selId] = prevId;
this._renderTree();
}
_outdent() {
const selId = this._selectedCgId || [...this.selectedIds][0];
if (!selId) return;
if (this._activeTab === 'parent' && !selId.startsWith('cg_')) { this._outdentParent(selId); return; }
// レイヤータブ: CGネスト or layerParent削除
const cgMap = {};
for (const cg of this.state.customGroups) cgMap[cg.id] = cg;
const itemParent = {};
for (const cg of this.state.customGroups) {
for (const lid of cg.layer_ids) itemParent[lid] = cg.id;
}
const lp = this.state.layerParent;
if (selId.startsWith('cg_')) {
const parentCgId = itemParent[selId];
if (!parentCgId) return;
const parentCg = cgMap[parentCgId];
const itemIdx = parentCg.layer_ids.indexOf(selId);
parentCg.layer_ids.splice(itemIdx, 1);
const grandParentCgId = itemParent[parentCgId];
if (!grandParentCgId) {
if (!this.state.cgOrder.includes(selId)) {
const pIdx = this.state.cgOrder.indexOf(parentCgId);
this.state.cgOrder.splice(pIdx !== -1 ? pIdx + 1 : this.state.cgOrder.length, 0, selId);
}
} else {
const grandParentCg = cgMap[grandParentCgId];
const pIdx = grandParentCg.layer_ids.indexOf(parentCgId);
grandParentCg.layer_ids.splice(pIdx + 1, 0, selId);
}
} else {
if (!lp[selId]) return;
delete lp[selId];
}
this._renderTree();
}
_outdentParent(selId) {
const lp = this.state.layerParent;
if (!lp[selId]) return;
const parentId = lp[selId];
// parentTabOrder 更新: 親の子リストから取り出し、ルートに挿入
const ch = this._ptGetChildren(parentId);
const ci = ch.indexOf(selId);
if (ci !== -1) ch.splice(ci, 1);
this.state.parentTabOrder.children[parentId] = ch;
const roots = this._ptGetRoots();
if (!roots.includes(selId)) {
const pi = roots.indexOf(parentId);
roots.splice(pi !== -1 ? pi + 1 : roots.length, 0, selId);
}
this.state.parentTabOrder.roots = roots;
delete lp[selId];
this._renderTree();
}
// ペアレントタブ ▲▼ ボタン: 兄弟間での順序変更(cgOrderに影響しない)
_shiftParentTabItem(dir) {
const selId = [...this.selectedIds][0];
if (!selId) return;
const lp = this.state.layerParent;
const parentId = lp[selId] || null;
const siblings = parentId === null ? this._ptGetRoots() : this._ptGetChildren(parentId);
const idx = siblings.indexOf(selId);
if (idx === -1) return;
if (dir === -1 && idx === 0) return;
if (dir === 1 && idx === siblings.length - 1) return;
[siblings[idx], siblings[idx + dir]] = [siblings[idx + dir], siblings[idx]];
if (parentId === null) this.state.parentTabOrder.roots = siblings;
else this.state.parentTabOrder.children[parentId] = siblings;
this._renderTree();
}
// parentTabOrder のルート一覧(なければ cgOrder から派生)
_ptGetRoots() {
const pto = this.state.parentTabOrder;
if (pto.roots) return [...pto.roots];
return this._ptDeriveRoots();
}
// parentTabOrder の子一覧(なければ cgOrder 順から派生)
_ptGetChildren(parentId) {
const pto = this.state.parentTabOrder;
if (pto.children[parentId]) return [...pto.children[parentId]];
return this._ptDeriveChildren(parentId);
}
_ptDeriveRoots() {
const lp = this.state.layerParent;
const cgIds = new Set(this.state.customGroups.map(cg => cg.id));
return this._ptAllOrderedIds().filter(id => !lp[id] || cgIds.has(lp[id]));
}
_ptDeriveChildren(parentId) {
const lp = this.state.layerParent;
const cgIds = new Set(this.state.customGroups.map(cg => cg.id));
if (cgIds.has(parentId)) return [];
return this._ptAllOrderedIds().filter(id => lp[id] === parentId);
}
// PSDレイヤーツリー順の全レイヤーIDリスト(CG構造を完全無視)
_ptAllOrderedIds() {
const result = [];
const collect = nodes => {
for (const n of [...nodes].reverse()) {
result.push(n.id);
if (n.children) collect(n.children);
}
};
collect(this.layerTree);
return result;
}
_apply() {
const configObj = this.state.toConfig();
function buildOrder(nodes) {
return nodes.map(n => {
const entry = { id: n.id };
if (n.children) entry.children = buildOrder(n.children);
return entry;
});
}
configObj.layer_order = buildOrder(this.layerTree);
const w = findWidget(this.node, "layer_config");
if (w) {
// キーフレームデータを既存 config から引き継ぐ
let existing = {};
try { existing = JSON.parse(w.value || "{}"); } catch (_) {}
if (existing.keyframes?.length) configObj.keyframes = existing.keyframes;
if (existing.kf_total_frames !== undefined) configObj.kf_total_frames = existing.kf_total_frames;
if (existing.kf_fps !== undefined) configObj.kf_fps = existing.kf_fps;
if (existing.pswEnabled !== undefined) configObj.pswEnabled = existing.pswEnabled;
w.value = JSON.stringify(configObj);
}
const node = this.node;
node._psdLayers = JSON.parse(JSON.stringify(this.layerTree));
// ノード内プレビューを更新
if (node._layerImages && node._psdW && node._psdH) {
drawNodeCanvas(node);
} else {
_setNodePreview(node, configObj);
}
this.destroy();
}
async _saveConfig() {
const name = prompt(t("promptModelName"));
if (!name || !name.trim()) return;
const configObj = this.state.toConfig();
const buildOrder = nodes => nodes.map(n => {
const e = { id: n.id };
if (n.children) e.children = buildOrder(n.children);
return e;
});
configObj.layer_order = buildOrder(this.layerTree);
configObj.pswEnabled = this.node._pswEnabled !== false;
const psdFilename = this.node._psdFilename || findWidget(this.node, "psd_filename")?.value || "";
const content = { psd_filename: psdFilename, layer_config: configObj };
const node = this.node;
if (node._nodeCanvas) {
content.thumbnail = captureThumbFromNode(node, 140, 140);
}
try {
const res = await fetch("/psd_loader/library/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: name.trim(), content }),
});
const d = await res.json();
if (d.error) throw new Error(d.error);
} catch (e) { alert(t("modelSaveFailed", e.message)); }
}
async _savePoseFromModal() {
const name = prompt(t("promptPoseName"));
if (!name || !name.trim()) return;
const config = this.state.toConfig();
const content = {
visibility: JSON.parse(JSON.stringify(config.visibility || {})),
pose: JSON.parse(JSON.stringify(config.pose || {})),
pswEnabled: this.node._pswEnabled !== false,
thumbnail: null,
};
const node = this.node;
{
const savedCam = { ...this._previewCam };
const wasShowing = this._showRigLabels;
this._previewCam = { ...this._defaultPreviewCam };
this._showRigLabels = false;
this._drawPreview();
const mof = this._computeModalOutputFrame();
if (mof && mof.sw > 0 && mof.sh > 0) {
const thumbW = 140;
const thumbH = Math.max(1, Math.round(thumbW * mof.sh / mof.sw));
const tc = document.createElement("canvas"); tc.width = thumbW; tc.height = thumbH;
tc.getContext("2d").drawImage(this._previewCanvas, mof.sx, mof.sy, mof.sw, mof.sh, 0, 0, thumbW, thumbH);
content.thumbnail = tc.toDataURL("image/png");
} else if (node._nodeCanvas) {
content.thumbnail = captureThumbFromNode(node, 140);
}
this._previewCam = savedCam;
this._showRigLabels = wasShowing;
this._drawPreview();
}
try {
const res = await fetch("/psd_loader/library/poses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: name.trim(), content }),
});
const d = await res.json();
if (d.error) throw new Error(d.error);
} catch (e) { alert(t("poseSaveFailed", e.message)); }
}
async _savePoseWithSwFromModal() {
const name = prompt(t("promptPoseNameWithSw"));
if (!name || !name.trim()) return;
const config = this.state.toConfig();
const swAngles = {};
(config.sw_layers || []).forEach(swl => {
(swl.points || []).forEach(pt => { swAngles[pt.id] = pt.angle ?? 0; });
});
const pswAngles = {};
(config.psw_layers || []).forEach(pswl => {
(pswl.points || []).forEach(pt => { pswAngles[pt.id] = pt.angle ?? 0; });
});
const content = {
visibility: JSON.parse(JSON.stringify(config.visibility || {})),
pose: JSON.parse(JSON.stringify(config.pose || {})),
sw_angles: swAngles,
psw_angles: pswAngles,
pswEnabled: this.node._pswEnabled !== false,
thumbnail: null,
};
const node = this.node;
{
const savedCam = { ...this._previewCam };
const wasShowing = this._showRigLabels;
this._previewCam = { ...this._defaultPreviewCam };
this._showRigLabels = false;
this._drawPreview();
const mof = this._computeModalOutputFrame();
if (mof && mof.sw > 0 && mof.sh > 0) {
const thumbW = 140;
const thumbH = Math.max(1, Math.round(thumbW * mof.sh / mof.sw));
const tc = document.createElement("canvas"); tc.width = thumbW; tc.height = thumbH;
tc.getContext("2d").drawImage(this._previewCanvas, mof.sx, mof.sy, mof.sw, mof.sh, 0, 0, thumbW, thumbH);
content.thumbnail = tc.toDataURL("image/png");
} else if (node._nodeCanvas) {
content.thumbnail = captureThumbFromNode(node, 140);
}
this._previewCam = savedCam;
this._showRigLabels = wasShowing;
this._drawPreview();
}
try {
const res = await fetch("/psd_loader/library/poses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: name.trim(), content }),
});
const d = await res.json();
if (d.error) throw new Error(d.error);
} catch (e) { alert(t("poseSaveFailed", e.message)); }
}
async _reloadFromNode() {
const layers = this.node._psdLayers || [];
let config = {};
try { config = JSON.parse(findWidget(this.node, "layer_config")?.value || "{}"); } catch (_) {}
this.layerTree = JSON.parse(JSON.stringify(layers));
this.state = LayerState.fromConfig(this.layerTree, config);
this.selectedIds = new Set();
this.collapsedIds = new Set();
this._selectedCgId = null;
if (config.rigging && Object.keys(config.rigging).length > 0 && this._rigMode === 'normal') {
this._rigMode = 'pose';
this._updateRigModeUI(this._setupBar);
}
this._renderTree();
await this._initPreview();
}
destroy() {
this._overlay?.remove();
this._overlay = null;
}
}
// ================================================
// モーダルを開く
// ================================================
async function openLayerModal(node) {
let layers = node._psdLayers;
const filename = node._psdFilename || findWidget(node, "psd_filename")?.value;
if (!layers && filename) {
try {
const res = await fetch(`/psd_loader/layers?filename=${encodeURIComponent(filename)}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
layers = data.layers; node._psdLayers = layers; node._psdW = data.width; node._psdH = data.height;
} catch (err) {
alert(t("layerFetchFailed", err.message));
// PSDが見つからなくてもモーダルは開く(新PC移行時など)
}
}
let existingConfig = {};
try { existingConfig = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
new PSDModal(node, layers || [], existingConfig);
}
// ================================================
// ライブラリモーダル
// ================================================
class LibraryModal {
constructor(node) {
this.node = node;
this._models = [];
this._poses = [];
this._dom = null;
this._modelFilter = "";
this._poseFilter = "";
this._poseThumbSize = "M"; // S / M / L
this._selectedModel = null;
this._selectedItem = null; // 現在ハイライト中のリストアイテム要素
}
async open() {
this._dom = this._buildDOM();
document.body.appendChild(this._dom);
await this._loadData();
}
_buildDOM() {
const S = (el, css) => { el.style.cssText = css; return el; };
const overlay = document.createElement("div");
S(overlay, "position:fixed;inset:0;background:rgba(0,0,0,0.72);z-index:9999;display:flex;align-items:center;justify-content:center;");
overlay.addEventListener("click", e => { if (e.target === overlay) this.destroy(); });
const container = document.createElement("div");
S(container, "background:#252535;border-radius:8px;width:720px;height:540px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.7);");
// header
const header = document.createElement("div");
S(header, "display:flex;align-items:center;justify-content:space-between;padding:8px 14px;border-bottom:1px solid #3d3d55;flex-shrink:0;");
const titleEl = Object.assign(document.createElement("span"), { textContent: "Library" });
S(titleEl, "font-size:14px;font-weight:bold;color:#dde;");
const closeBtn = Object.assign(document.createElement("button"), { textContent: "×" });
S(closeBtn, "background:none;border:none;color:#999;font-size:20px;cursor:pointer;line-height:1;padding:0 4px;");
closeBtn.onclick = () => this.destroy();
header.append(titleEl, closeBtn);
// body
const body = document.createElement("div");
S(body, "display:flex;flex:1;overflow:hidden;min-height:0;");
// --- left panel (model list + thumbnail) ---
const leftPanel = document.createElement("div");
S(leftPanel, "width:155px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid #3d3d55;");
const leftTop = document.createElement("div");
S(leftTop, "padding:6px 8px;border-bottom:1px solid #3d3d55;flex-shrink:0;");
const modelSearch = Object.assign(document.createElement("input"), { type: "text", placeholder: "psdmodel" });
S(modelSearch, "width:100%;box-sizing:border-box;background:#1a1a2a;border:1px solid #4a4a66;color:#dde;padding:4px 6px;border-radius:4px;font-size:11px;outline:none;");
modelSearch.addEventListener("input", () => { this._modelFilter = modelSearch.value.toLowerCase(); this._renderModels(); });
leftTop.appendChild(modelSearch);
this._modelListEl = document.createElement("div");
S(this._modelListEl, "flex:1;overflow-y:auto;padding:4px;display:flex;flex-direction:column;gap:1px;min-height:0;");
// 選択モデルサムネイル(左パネル下部)
this._modelThumbEl = document.createElement("div");
S(this._modelThumbEl, "flex-shrink:0;height:106px;border-top:1px solid #3d3d55;display:flex;align-items:center;justify-content:center;background:#1a1a2a;overflow:hidden;");
const thumbPlaceholder = Object.assign(document.createElement("span"), { textContent: t("selectModel") });
S(thumbPlaceholder, "font-size:10px;color:#444;");
this._modelThumbEl.appendChild(thumbPlaceholder);
leftPanel.append(leftTop, this._modelListEl, this._modelThumbEl);
// --- right panel (pose grid) ---
const rightPanel = document.createElement("div");
S(rightPanel, "flex:1;display:flex;flex-direction:column;overflow:hidden;");
const rightTop = document.createElement("div");
S(rightTop, "display:flex;align-items:center;gap:6px;padding:6px 8px;border-bottom:1px solid #3d3d55;flex-shrink:0;");
const poseSearch = Object.assign(document.createElement("input"), { type: "text", placeholder: t("poseSearchPlaceholder") });
S(poseSearch, "flex:1;background:#1a1a2a;border:1px solid #4a4a66;color:#dde;padding:4px 6px;border-radius:4px;font-size:11px;outline:none;");
poseSearch.addEventListener("input", () => { this._poseFilter = poseSearch.value.toLowerCase(); this._renderPoses(); });
// S/M/L サイズ切り替えボタン
const sizeGroup = document.createElement("div");
S(sizeGroup, "display:flex;gap:2px;flex-shrink:0;");
["S", "M", "L"].forEach(sz => {
const btn = Object.assign(document.createElement("button"), { textContent: sz });
S(btn, `background:${sz === this._poseThumbSize ? "#4a4a88" : "#2a2a44"};border:1px solid #4a4a66;color:#ccd;padding:3px 7px;border-radius:3px;cursor:pointer;font-size:11px;`);
btn.dataset.sz = sz;
btn.addEventListener("click", () => {
this._poseThumbSize = sz;
sizeGroup.querySelectorAll("button").forEach(b => {
b.style.background = b.dataset.sz === sz ? "#4a4a88" : "#2a2a44";
});
this._renderPoses();
});
sizeGroup.appendChild(btn);
});
rightTop.append(poseSearch, sizeGroup);
this._poseGridEl = document.createElement("div");
S(this._poseGridEl, "flex:1;overflow-y:auto;padding:8px;align-content:start;");
this._updateGridCss();
rightPanel.append(rightTop, this._poseGridEl);
body.append(leftPanel, rightPanel);
container.append(header, body);
overlay.appendChild(container);
return overlay;
}
_thumbPx() { return { S: 76, M: 110, L: 160 }[this._poseThumbSize] ?? 110; }
_updateGridCss() {
const px = this._thumbPx();
this._poseGridEl.style.cssText = `flex:1;overflow-y:auto;padding:8px;display:grid;grid-template-columns:repeat(auto-fill,minmax(${px}px,1fr));gap:6px;align-content:start;`;
}
async _loadData() {
this._modelListEl.textContent = t("loading");
this._poseGridEl.textContent = t("loading");
try {
const [mr, pr] = await Promise.all([
fetch("/psd_loader/library/models"),
fetch("/psd_loader/library/poses"),
]);
this._models = await mr.json();
this._poses = await pr.json();
} catch (_) {
this._models = []; this._poses = [];
}
this._renderModels();
this._renderPoses();
}
_renderModels() {
const q = this._modelFilter;
const list = q ? this._models.filter(m => m.name.toLowerCase().includes(q)) : this._models;
this._modelListEl.innerHTML = "";
this._selectedItem = null;
if (!list.length) {
const e = Object.assign(document.createElement("div"), { textContent: t("noSavedModels") });
e.style.cssText = "color:#555;font-size:11px;padding:10px;text-align:center;";
this._modelListEl.appendChild(e);
return;
}
list.forEach(m => {
const item = document.createElement("div");
const isSel = this._selectedModel?.filename === m.filename;
item.style.cssText = `padding:5px 7px;border-radius:4px;cursor:pointer;color:#ccd;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none;background:${isSel ? "#3a3a60" : ""};`;
item.textContent = m.name;
item.title = m.psd_filename ? `PSD: ${m.psd_filename}` : m.name;
if (isSel) this._selectedItem = item;
item.addEventListener("mouseover", () => { if (this._selectedModel?.filename !== m.filename) item.style.background = "#2d2d4a"; });
item.addEventListener("mouseout", () => { if (this._selectedModel?.filename !== m.filename) item.style.background = ""; });
item.addEventListener("click", () => this._selectModel(m, item));
item.addEventListener("dblclick", () => this._loadModel(m));
item.addEventListener("contextmenu", e => { e.preventDefault(); this._deleteModel(m); });
this._modelListEl.appendChild(item);
});
}
_selectModel(m, itemEl) {
if (this._selectedItem) {
this._selectedItem.style.background = "";
}
this._selectedModel = m;
this._selectedItem = itemEl;
if (itemEl) itemEl.style.background = "#3a3a60";
this._modelThumbEl.innerHTML = "";
if (m.thumbnail) {
const img = document.createElement("img");
img.src = m.thumbnail;
img.style.cssText = "max-width:100%;max-height:100%;width:auto;height:auto;";
this._modelThumbEl.appendChild(img);
} else {
const ph = Object.assign(document.createElement("span"), { textContent: t("noThumbnail") });
ph.style.cssText = "font-size:10px;color:#444;";
this._modelThumbEl.appendChild(ph);
}
}
_renderPoses() {
const q = this._poseFilter;
const list = q ? this._poses.filter(p => p.name.toLowerCase().includes(q)) : this._poses;
this._updateGridCss();
this._poseGridEl.innerHTML = "";
const px = this._thumbPx();
if (!list.length) {
const e = Object.assign(document.createElement("div"), { textContent: t("noSavedPoses") });
e.style.cssText = "color:#555;font-size:11px;padding:16px;text-align:center;grid-column:1/-1;";
this._poseGridEl.appendChild(e);
return;
}
list.forEach(p => {
const card = document.createElement("div");
card.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:3px;cursor:pointer;border-radius:5px;padding:3px;border:2px solid transparent;";
card.addEventListener("mouseover", () => card.style.borderColor = "#5a5a8a");
card.addEventListener("mouseout", () => card.style.borderColor = "transparent");
card.addEventListener("click", () => this._loadPose(p));
card.addEventListener("contextmenu", e => { e.preventDefault(); this._deletePose(p); });
// サムネイルボックス(正方形、object-fit:contain で縦横比を保持)
const thumbBox = document.createElement("div");
thumbBox.style.cssText = `width:${px}px;height:${px}px;background:#1a1a2a;border-radius:3px;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;`;
if (p.thumbnail) {
const img = document.createElement("img");
img.src = p.thumbnail;
img.style.cssText = `max-width:${px}px;max-height:${px}px;width:auto;height:auto;`;
thumbBox.appendChild(img);
} else {
thumbBox.textContent = "🧍";
thumbBox.style.fontSize = `${Math.round(px * 0.32)}px`;
thumbBox.style.color = "#333";
}
const label = Object.assign(document.createElement("div"), { textContent: p.name });
label.style.cssText = "font-size:10px;color:#aab;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
card.append(thumbBox, label);
this._poseGridEl.appendChild(card);
});
}
async _loadModel(m) {
try {
const res = await fetch(`/psd_loader/library/models/${encodeURIComponent(m.filename)}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
const node = this.node;
if (data.layer_config) {
const lw = findWidget(node, "layer_config");
if (lw) lw.value = JSON.stringify(data.layer_config);
if (data.layer_config.pswEnabled !== undefined) {
node._pswEnabled = data.layer_config.pswEnabled;
if (node._updatePswToggle) node._updatePswToggle();
}
}
if (data.psd_filename) {
const fw = findWidget(node, "psd_filename");
if (fw) fw.value = data.psd_filename;
node._psdFilename = data.psd_filename;
if (node._psdBtn) node._psdBtn.textContent = `📂 ${data.psd_filename}`;
try {
const lr = await fetch(`/psd_loader/layers?filename=${encodeURIComponent(data.psd_filename)}`);
const ld = await lr.json();
if (!ld.error) {
node._psdLayers = ld.layers; node._psdW = ld.width; node._psdH = ld.height;
node._layerImages = await loadLayerImages(data.psd_filename, ld.layers);
setDefaultCamera(node);
drawNodeCanvas(node);
}
} catch (_) {}
}
this.destroy();
} catch (e) { alert(t("modelLoadFailed", e.message)); }
}
async _loadPose(p) {
try {
const res = await fetch(`/psd_loader/library/poses/${encodeURIComponent(p.filename)}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
const node = this.node;
let config = {};
try { config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
if (data._type === "kf_project" && Array.isArray(data.keyframes)) {
// ---- キーフレームプロジェクトの復元 ----
node._keyframes = JSON.parse(JSON.stringify(data.keyframes));
if (data.kf_total_frames !== undefined) {
node._kfTotalFrames = data.kf_total_frames;
if (node._kfTotalFramesEl) node._kfTotalFramesEl.value = data.kf_total_frames;
}
if (data.kf_fps !== undefined) {
node._kfFps = data.kf_fps;
if (node._kfFpsEl) node._kfFpsEl.value = data.kf_fps;
}
// layer_config に永続化
config.keyframes = JSON.parse(JSON.stringify(node._keyframes));
config.kf_total_frames = node._kfTotalFrames;
config.kf_fps = node._kfFps;
const w = findWidget(node, "layer_config");
if (w) w.value = JSON.stringify(config);
updateTimelineCanvas(node);
seekToFrame(node, 0); // フレーム0にシーク(ポーズも適用される)
} else {
// ---- 通常ポーズの復元 ----
if (data.visibility) config.visibility = JSON.parse(JSON.stringify(data.visibility));
if (data.pose) config.pose = JSON.parse(JSON.stringify(data.pose));
if (data.sw_angles && config.sw_layers) {
config.sw_layers.forEach(swl => {
(swl.points || []).forEach(pt => {
if (data.sw_angles[pt.id] !== undefined) pt.angle = data.sw_angles[pt.id];
});
});
}
if (data.psw_angles && config.psw_layers) {
config.psw_layers.forEach(pswl => {
(pswl.points || []).forEach(pt => {
if (data.psw_angles[pt.id] !== undefined) pt.angle = data.psw_angles[pt.id];
});
});
}
if (data.pswEnabled !== undefined) {
node._pswEnabled = data.pswEnabled;
if (node._updatePswToggle) node._updatePswToggle();
config.pswEnabled = data.pswEnabled;
}
const w = findWidget(node, "layer_config");
if (w) w.value = JSON.stringify(config);
drawNodeCanvas(node);
}
this.destroy();
} catch (e) { alert(t("poseLoadFailed", e.message)); }
}
async _deleteModel(m) {
if (!confirm(t("confirmDeleteModel", m.name))) return;
await fetch(`/psd_loader/library/models/${encodeURIComponent(m.filename)}`, { method: "DELETE" });
if (this._selectedModel?.filename === m.filename) {
this._selectedModel = null;
this._selectedItem = null;
this._modelThumbEl.innerHTML = "";
const ph = Object.assign(document.createElement("span"), { textContent: t("selectModel") });
ph.style.cssText = "font-size:10px;color:#444;";
this._modelThumbEl.appendChild(ph);
}
await this._loadData();
}
async _deletePose(p) {
if (!confirm(t("confirmDeletePose", p.name))) return;
await fetch(`/psd_loader/library/poses/${encodeURIComponent(p.filename)}`, { method: "DELETE" });
await this._loadData();
}
destroy() {
this._dom?.remove();
this._dom = null;
}
}
// ================================================
// プレビューキャンバスのマウス操作(パン・ズーム)
// ================================================
function setupPreviewInteraction(canvas, node) {
let isPanning = false;
let isRolling = false;
let isRigDrag = false;
let rigDragInfo = null;
let lastMX = 0, lastMY = 0;
let rollStartX = 0, rollStartRoll = 0;
// キャンバスピクセル座標 → PSD(ワールド)座標
const toWorld = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const sx = (clientX - rect.left) * (canvas.width / rect.width);
const sy = (clientY - rect.top) * (canvas.height / rect.height);
const cam = node._camera;
const W = canvas.width, H = canvas.height;
const rx = sx - W / 2;
const ry = sy - H / 2;
const roll = cam.roll || 0;
const cos = Math.cos(-roll), sin = Math.sin(-roll);
const rrx = rx * cos - ry * sin;
const rry = rx * sin + ry * cos;
return {
x: rrx / cam.zoom + W / 2 - cam.x,
y: rry / cam.zoom + H / 2 - cam.y,
};
};
canvas.addEventListener("mousedown", e => {
lastMX = e.clientX;
lastMY = e.clientY;
e.preventDefault();
// Alt+右ドラッグ → カメラロール
if (e.altKey && e.button === 2) {
isRolling = true;
rollStartX = e.clientX;
rollStartRoll = node._camera.roll || 0;
canvas.style.cursor = "ew-resize";
return;
}
// リグポイント・SWポイント・PSWポイントのヒットテスト
if (node._layerImages && node._psdLayers) {
let config = {};
try { config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
if (config.rigging || config.sw_layers?.length || config.psw_layers?.length) {
const cam = node._camera;
const { x: wx, y: wy } = toWorld(e.clientX, e.clientY);
const imgMap = getEffectiveImageMap(node, config);
// PSWポーズを適用した有効ポーズでヒットテスト
const effectivePose = computePswEffectivePose(config.pose || {}, config.psw_layers || [], null, node._pswEnabled !== false);
const hit = hitTestRig(wx, wy, node._psdLayers, imgMap,
config.rigging || {}, effectivePose, 'pose', cam.zoom,
config.layer_parent || {}, config.custom_groups || [], node._rigPointSize ?? 1.0,
config.sw_layers || [], config.psw_layers || []);
if (hit && (e.altKey || e.ctrlKey) && (hit.type === 'r' || hit.type === 'mr')) {
if (!config.pose) config.pose = {};
if (!config.pose[hit.layerId]) config.pose[hit.layerId] = { angle: 0, tx: 0, ty: 0 };
const p = config.pose[hit.layerId];
if (e.altKey) p.flipX = !p.flipX;
if (e.ctrlKey) p.flipY = !p.flipY;
const w = findWidget(node, "layer_config");
if (w) w.value = JSON.stringify(config);
drawNodeCanvas(node);
return;
}
if (hit && hit.type === 'sw_handle') {
isRigDrag = true;
const swLayer = (config.sw_layers || []).find(l => l.id === hit.swLayerId);
const swInfo = swLayer?.points?.find(p => p.id === hit.swPointId);
if (swInfo) {
rigDragInfo = { hit, config,
pivotX: swInfo.x ?? 0,
pivotY: swInfo.y ?? 0 };
node._selectedSwPointInfo = { swLayerId: hit.swLayerId, pointId: hit.swPointId };
canvas.style.cursor = "crosshair";
return;
}
}
if (hit && hit.type === 'psw_handle') {
isRigDrag = true;
const pswLayer = (config.psw_layers || []).find(l => l.id === hit.pswLayerId);
const pswInfo = pswLayer?.points?.find(p => p.id === hit.pswPointId);
if (pswInfo) {
rigDragInfo = { hit, config,
pivotX: pswInfo.x ?? 0,
pivotY: pswInfo.y ?? 0 };
node._selectedPswPointInfo = { pswLayerId: hit.pswLayerId, pointId: hit.pswPointId };
canvas.style.cursor = "crosshair";
return;
}
}
if (hit && hit.layerId) {
isRigDrag = true;
const rig = config.rigging[hit.layerId];
const entry = imgMap[hit.layerId];
const pose = config.pose?.[hit.layerId] || { angle: 0, tx: 0, ty: 0 };
let pivotX = 0, pivotY = 0, initAngle = 0;
const chain = buildAncestorChain(hit.layerId, config.layer_parent || {},
config.rigging, config.pose, imgMap);
if (hit.type === 'r' && rig.r) {
const local = { x: entry.left + rig.r.x + (pose.tx ?? 0), y: entry.top + rig.r.y + (pose.ty ?? 0) };
const vis = chain.length > 0 ? applyChainToPoint(local.x, local.y, chain) : local;
pivotX = vis.x; pivotY = vis.y;
initAngle = Math.atan2(wy - pivotY, wx - pivotX);
} else if (hit.type === 'mr' && rig.mr) {
const local = { x: entry.left + rig.mr.x, y: entry.top + rig.mr.y };
const vis = chain.length > 0 ? applyChainToPoint(local.x, local.y, chain) : local;
pivotX = vis.x; pivotY = vis.y;
initAngle = Math.atan2(wy - pivotY, wx - pivotX);
}
rigDragInfo = { hit, config, pivotX, pivotY,
initAngle, startAngle: pose.angle ?? 0,
startTx: pose.tx ?? 0, startTy: pose.ty ?? 0, chain };
node._rigSelectedLayerId = hit.layerId;
canvas.style.cursor = "crosshair";
return;
}
}
}
isPanning = true;
canvas.style.cursor = "grabbing";
});
canvas.addEventListener("mousemove", e => {
if (isRolling) {
const dx = e.clientX - rollStartX;
node._camera.roll = rollStartRoll + dx * 0.005;
drawNodeCanvas(node);
return;
}
if (isRigDrag && rigDragInfo) {
const { x: wx, y: wy } = toWorld(e.clientX, e.clientY);
const { hit, config, pivotX, pivotY, initAngle, startAngle, startTx, startTy } = rigDragInfo;
const layerId = hit.layerId;
const rig = (config.rigging || {})[layerId];
const entry = getEffectiveImageMap(node, config)[layerId];
if (!config.pose) config.pose = {};
if (!config.pose[layerId]) config.pose[layerId] = { angle: 0, tx: 0, ty: 0 };
const p = config.pose[layerId];
if (hit.type === 'sw_handle') {
// SWハンドルの角度変更
const swLayer = (config.sw_layers || []).find(l => l.id === hit.swLayerId);
const swInfo = swLayer?.points?.find(p => p.id === hit.swPointId);
if (swInfo) swInfo.angle = Math.atan2(wy - rigDragInfo.pivotY, wx - rigDragInfo.pivotX);
} else if (hit.type === 'psw_handle') {
// PSWハンドルの角度変更
const pswLayer = (config.psw_layers || []).find(l => l.id === hit.pswLayerId);
const pswInfo = pswLayer?.points?.find(p => p.id === hit.pswPointId);
if (pswInfo) {
const _raw = Math.atan2(wy - rigDragInfo.pivotY, wx - rigDragInfo.pivotX);
const _norm = ((_raw % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
pswInfo.angle = Math.min(_norm, (Math.PI / 6) * Math.max((pswInfo.slots?.length || 1) - 1, 0));
}
} else if (hit.type === 'r' || hit.type === 'mr') {
const curAngle = Math.atan2(wy - pivotY, wx - pivotX);
p.angle = startAngle + (curAngle - initAngle);
} else if (hit.type === 'orange') {
const rawSX = entry.left + rig.mr.x;
const rawSY = entry.top + rig.mr.y;
const chain = rigDragInfo.chain || [];
let localWx = wx, localWy = wy;
if (chain.length > 0) {
const inv = inverseChainToPoint(wx, wy, chain);
localWx = inv.x; localWy = inv.y;
}
let newTx = localWx - rawSX;
let newTy = localWy - rawSY;
if (rig.mr_radius > 0) {
const dist = Math.hypot(newTx, newTy);
if (dist > rig.mr_radius) {
newTx = newTx / dist * rig.mr_radius;
newTy = newTy / dist * rig.mr_radius;
}
}
p.tx = newTx; p.ty = newTy;
}
// layer_config ウィジェットを更新して再描画
const w = findWidget(node, "layer_config");
if (w) w.value = JSON.stringify(config);
drawNodeCanvas(node);
return;
}
if (!isPanning) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const cam = node._camera;
cam.x += (e.clientX - lastMX) * scaleX / cam.zoom;
cam.y += (e.clientY - lastMY) * scaleY / cam.zoom;
lastMX = e.clientX;
lastMY = e.clientY;
drawNodeCanvas(node);
});
canvas.addEventListener("mouseup", () => {
isPanning = false; isRolling = false; isRigDrag = false; rigDragInfo = null;
canvas.style.cursor = "grab";
});
canvas.addEventListener("mouseleave", () => {
isPanning = false; isRolling = false; isRigDrag = false; rigDragInfo = null;
canvas.style.cursor = "grab";
});
// ズーム(カーソル位置中心)
canvas.addEventListener("wheel", e => {
e.preventDefault();
const cam = node._camera;
const oldZoom = cam.zoom;
const factor = 1.12;
cam.zoom = e.deltaY < 0
? Math.min(20.0, cam.zoom * factor)
: Math.max(0.05, cam.zoom / factor);
const rect = canvas.getBoundingClientRect();
const cvsX = (e.clientX - rect.left) * (canvas.width / rect.width);
const cvsY = (e.clientY - rect.top) * (canvas.height / rect.height);
const W = canvas.width, H = canvas.height;
cam.x += (cvsX - W / 2) * (1 / cam.zoom - 1 / oldZoom);
cam.y += (cvsY - H / 2) * (1 / cam.zoom - 1 / oldZoom);
drawNodeCanvas(node);
}, { passive: false });
canvas.addEventListener("contextmenu", e => e.preventDefault());
}
// ================================================
// プレビューエリア固定サイズ定数
// ================================================
const MODAL_CANVAS_W = 366; // モーダルプレビューキャンバス内部解像度(パネル幅390 - padding*2)
const MODAL_CANVAS_H = 520; // モーダルプレビューキャンバス内部解像度
const PREVIEW_IMG_H = 422;
const PREVIEW_NODE_W = 474;
const PREVIEW_CANVAS_W = PREVIEW_NODE_W - 12; // 462px
const BTN_ROW_H = 30;
const PREVIEW_WIDGET_H = PREVIEW_IMG_H + 108; // 530 (+10 wrap padding, +2 border, +18 status, +26 slider, +26 bg row, +26 margin)
const UI_WIDGET_H = BTN_ROW_H * 2 + PREVIEW_WIDGET_H; // 60 + 530 = 590
const KF_PANEL_H = 98; // キーフレームパネルの高さ(行A+タイムライン+行B+パディング)
// ================================================
// キーフレームパネル DOM を作成
// ================================================
function buildKeyframePanel(node) {
const panel = document.createElement("div");
panel.style.cssText = [
"width:100%", "box-sizing:border-box",
"padding:3px 6px 5px", "display:none", // 初期非表示
"flex-direction:column", "gap:3px",
"background:#13131f", "border-top:1px solid #313244",
].join(";");
const kfBtnSt = [
"background:#313244", "border:1px solid #45475a",
"border-radius:3px", "color:#cdd6f4",
"cursor:pointer", "padding:2px 7px",
"font-size:11px", "white-space:nowrap",
].join(";");
// ---- 行A: 操作ボタン ----
const rowA = document.createElement("div");
rowA.style.cssText = "display:flex;align-items:center;gap:4px;height:26px;";
const addBtn = document.createElement("button");
addBtn.style.cssText = kfBtnSt + ";background:#1a3a2a;border-color:#2a6a3a;";
addBtn.textContent = t("kfAddBtn");
addBtn.title = t("kfAddTooltip");
addBtn.onclick = () => addKeyframeAtCurrentFrame(node);
const delBtn = document.createElement("button");
delBtn.style.cssText = kfBtnSt + ";background:#3a1a1a;border-color:#6a2a2a;";
delBtn.textContent = t("kfDelBtn");
delBtn.title = t("kfDelTooltip");
delBtn.onclick = () => deleteKeyframeAtCurrentFrame(node);
const sep1 = document.createElement("span");
sep1.style.cssText = "color:#45475a;font-size:11px;";
sep1.textContent = "|";
const addCamBtn = document.createElement("button");
addCamBtn.style.cssText = kfBtnSt + ";background:#1a2a3a;border-color:#2a5a7a;";
addCamBtn.textContent = t("kfAddCamBtn");
addCamBtn.title = t("kfAddCamTooltip");
addCamBtn.onclick = () => addCameraKeyframeAtCurrentFrame(node);
const delCamBtn = document.createElement("button");
delCamBtn.style.cssText = kfBtnSt + ";background:#2a1a2a;border-color:#5a2a5a;";
delCamBtn.textContent = t("kfDelCamBtn");
delCamBtn.title = t("kfDelCamTooltip");
delCamBtn.onclick = () => deleteCameraKeyframeAtCurrentFrame(node);
const sep1b = document.createElement("span");
sep1b.style.cssText = "color:#45475a;font-size:11px;";
sep1b.textContent = "|";
const moveKeyBtn = document.createElement("button");
moveKeyBtn.style.cssText = kfBtnSt;
moveKeyBtn.textContent = t("kfMoveKeyBtn");
moveKeyBtn.title = t("kfMoveKeyTooltip");
moveKeyBtn.onclick = () => {
node._kfMoveKeyMode = !node._kfMoveKeyMode;
moveKeyBtn.style.cssText = node._kfMoveKeyMode
? kfBtnSt + ";background:#3a2a4a;border-color:#8a5a9a;outline:1px solid #aa77cc;"
: kfBtnSt;
};
const sep1c = document.createElement("span");
sep1c.style.cssText = "color:#45475a;font-size:11px;";
sep1c.textContent = "|";
const goToZeroBtn = document.createElement("button");
goToZeroBtn.style.cssText = kfBtnSt;
goToZeroBtn.textContent = t("kfGoToZeroBtn");
goToZeroBtn.title = t("kfGoToZeroTooltip");
goToZeroBtn.onclick = () => seekToFrame(node, 0);
const prevBtn = document.createElement("button");
prevBtn.style.cssText = kfBtnSt;
prevBtn.textContent = "◀";
prevBtn.title = "前のフレーム";
prevBtn.onclick = () => seekToFrame(node, Math.max(0, node._kfCurrentFrame - 1));
const frameInput = document.createElement("input");
frameInput.type = "number";
frameInput.min = "0";
frameInput.style.cssText = "width:44px;background:#1a1a2a;border:1px solid #45475a;border-radius:3px;color:#cdd6f4;font-size:11px;padding:2px 4px;text-align:center;";
frameInput.value = "0";
frameInput.addEventListener("change", () => {
const f = parseInt(frameInput.value) || 0;
seekToFrame(node, f);
});
node._kfCurrentFrameEl = frameInput;
const frameSlash = document.createElement("span");
frameSlash.style.cssText = "color:#888;font-size:11px;";
frameSlash.textContent = "/";
const totalInput = document.createElement("input");
totalInput.type = "number";
totalInput.min = "2";
totalInput.style.cssText = "width:44px;background:#1a1a2a;border:1px solid #45475a;border-radius:3px;color:#cdd6f4;font-size:11px;padding:2px 4px;text-align:center;";
totalInput.value = "60";
totalInput.addEventListener("change", () => {
node._kfTotalFrames = Math.max(2, parseInt(totalInput.value) || 60);
const w = findWidget(node, "layer_config");
if (w) {
let cfg = {}; try { cfg = JSON.parse(w.value || "{}"); } catch (_) {}
cfg.kf_total_frames = node._kfTotalFrames;
w.value = JSON.stringify(cfg);
}
updateTimelineCanvas(node);
});
node._kfTotalFramesEl = totalInput;
const nextBtn = document.createElement("button");
nextBtn.style.cssText = kfBtnSt;
nextBtn.textContent = "▶";
nextBtn.title = "次のフレーム";
nextBtn.onclick = () => seekToFrame(node, node._kfCurrentFrame + 1);
const sep2 = document.createElement("span");
sep2.style.cssText = "color:#45475a;font-size:11px;margin-left:auto;";
sep2.textContent = "|";
const playBtn = document.createElement("button");
playBtn.style.cssText = kfBtnSt + ";background:#1a2a4a;border-color:#2a4a8a;min-width:54px;";
playBtn.textContent = t("kfPlayBtn");
playBtn.title = t("kfPlayTooltip");
playBtn.onclick = () => {
if (node._kfPlaying) stopPlayback(node);
else startPlayback(node);
};
node._kfPlayBtn = playBtn;
const stopBtn = document.createElement("button");
stopBtn.style.cssText = kfBtnSt;
stopBtn.textContent = t("kfStopBtn");
stopBtn.title = t("kfStopTooltip");
stopBtn.onclick = () => stopPlayback(node);
rowA.append(addBtn, delBtn, sep1, addCamBtn, delCamBtn, sep1b, moveKeyBtn, sep1c, goToZeroBtn, prevBtn, frameInput, frameSlash, totalInput, nextBtn);
// ---- タイムラインキャンバス ----
const tlCanvas = document.createElement("canvas");
tlCanvas.width = 450;
tlCanvas.height = 34;
tlCanvas.style.cssText = "width:100%;height:34px;cursor:pointer;border-radius:3px;display:block;";
node._kfTimelineCanvas = tlCanvas;
setupTimelineInteraction(tlCanvas, node);
// ---- 行B: FPS + エクスポート ----
const rowB = document.createElement("div");
rowB.style.cssText = "display:flex;align-items:center;gap:4px;height:26px;";
const clearBtn = document.createElement("button");
clearBtn.style.cssText = kfBtnSt + ";background:#3a2a1a;border-color:#8a5a2a;";
clearBtn.textContent = t("kfClearBtn");
clearBtn.title = t("kfClearTooltip");
clearBtn.onclick = () => {
if (!confirm(t("kfClearTooltip") + "\n" + (node._keyframes?.length ? `(${node._keyframes.length}件)` : ""))) return;
node._keyframes = [];
node._kfCurrentFrame = 0;
if (node._kfCurrentFrameEl) node._kfCurrentFrameEl.value = "0";
const w = findWidget(node, "layer_config");
if (w) {
let cfg = {}; try { cfg = JSON.parse(w.value || "{}"); } catch (_) {}
delete cfg.keyframes;
w.value = JSON.stringify(cfg);
}
updateTimelineCanvas(node);
};
const fpsLabel = document.createElement("span");
fpsLabel.style.cssText = "font-size:11px;color:#cdd6f4;flex-shrink:0;";
fpsLabel.textContent = t("kfFpsLabel") + ":";
const fpsInput = document.createElement("input");
fpsInput.type = "number";
fpsInput.min = "1";
fpsInput.max = "60";
fpsInput.style.cssText = "width:38px;background:#1a1a2a;border:1px solid #45475a;border-radius:3px;color:#cdd6f4;font-size:11px;padding:2px 4px;text-align:center;";
fpsInput.value = "24";
fpsInput.addEventListener("change", () => {
node._kfFps = Math.max(1, Math.min(60, parseInt(fpsInput.value) || 24));
const w = findWidget(node, "layer_config");
if (w) {
let cfg = {}; try { cfg = JSON.parse(w.value || "{}"); } catch (_) {}
cfg.kf_fps = node._kfFps;
w.value = JSON.stringify(cfg);
}
});
node._kfFpsEl = fpsInput;
const progressEl = document.createElement("span");
progressEl.style.cssText = "font-size:10px;color:#888;min-width:50px;";
node._kfProgressEl = progressEl;
// ---- プロジェクト保存ボタン ----
const saveProjBtn = document.createElement("button");
saveProjBtn.style.cssText = kfBtnSt + ";background:#2a1a4a;border-color:#5a3a8a;";
saveProjBtn.textContent = t("kfSaveProjBtn");
saveProjBtn.title = t("kfSaveProjTooltip");
saveProjBtn.onclick = async () => {
saveProjBtn.disabled = true;
const ok = await saveKeyframeProject(node);
saveProjBtn.disabled = false;
if (ok) {
saveProjBtn.style.outline = "2px solid #aa88ff";
setTimeout(() => { saveProjBtn.style.outline = "none"; }, 800);
}
};
const exportBtn = document.createElement("button");
exportBtn.style.cssText = kfBtnSt + ";background:#1a2a4a;border-color:#2a4a8a;";
exportBtn.textContent = t("kfExportBtn");
exportBtn.title = t("kfExportTooltip");
exportBtn.onclick = async () => {
if (!(node._keyframes?.length >= 2)) {
alert(t("kfNoKeyframes") + " (最低2つ必要)");
return;
}
exportBtn.disabled = true;
exportBtn.textContent = t("kfExporting");
try {
const blob = await exportVideoWebM(node, (f, total) => {
if (node._kfProgressEl) node._kfProgressEl.textContent = `${f}/${total}`;
});
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "animation.webm";
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
} catch (e) {
console.error("[PSDFigureCreator] exportVideoWebM error:", e);
alert("Export failed: " + e.message);
}
if (node._kfProgressEl) node._kfProgressEl.textContent = "";
exportBtn.textContent = t("kfExportBtn");
exportBtn.disabled = false;
};
const gifBtn = document.createElement("button");
gifBtn.style.cssText = kfBtnSt + ";background:#1a3a2a;border-color:#2a7a4a;";
gifBtn.textContent = t("kfGifExportBtn");
gifBtn.title = t("kfGifExportTooltip");
gifBtn.onclick = async () => {
if (!(node._keyframes?.length >= 2)) {
alert(t("kfNoKeyframes") + " (最低2つ必要)");
return;
}
gifBtn.disabled = true;
gifBtn.textContent = t("kfGifExporting");
try {
const blob = await exportAnimatedGIF(node, (f, total) => {
if (node._kfProgressEl) node._kfProgressEl.textContent = `GIF ${f}/${total}`;
});
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "animation.gif";
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
} catch (e) {
console.error("[PSDFigureCreator] exportAnimatedGIF error:", e);
alert("GIF Export failed: " + e.message);
}
if (node._kfProgressEl) node._kfProgressEl.textContent = "";
gifBtn.textContent = t("kfGifExportBtn");
gifBtn.disabled = false;
};
const rowBSpacer = document.createElement("span");
rowBSpacer.style.cssText = "flex:1;";
rowB.append(clearBtn, fpsLabel, fpsInput, saveProjBtn, exportBtn, gifBtn, rowBSpacer, progressEl, playBtn, stopBtn);
panel.append(rowA, tlCanvas, rowB);
return panel;
}
// ================================================
// プレビューエリアDOMを作成
// ================================================
function buildPreviewWidget(node) {
const wrap = document.createElement("div");
wrap.style.cssText = "width:100%;padding:4px 6px 6px;box-sizing:border-box;";
const previewBox = document.createElement("div");
previewBox.style.cssText = [
"width:100%",
`height:${PREVIEW_IMG_H}px`,
"position:relative",
"overflow:hidden",
"border-radius:4px",
"border:1px solid #313244",
"background:#111",
"box-sizing:border-box",
].join(";");
// インタラクティブキャンバス
const canvas = document.createElement("canvas");
canvas.width = PREVIEW_CANVAS_W;
canvas.height = PREVIEW_IMG_H;
canvas.style.cssText = [
"position:absolute",
"top:0;left:0",
"width:100%;height:100%",
"cursor:grab",
"display:block",
].join(";");
previewBox.appendChild(canvas);
const statusEl = document.createElement("div");
statusEl.style.cssText = "font-size:10px;color:#6c7086;text-align:center;min-height:14px;margin-top:4px;";
// ---- ポイントサイズスライダー ----
const sliderWrap = document.createElement("div");
sliderWrap.style.cssText = "display:flex;align-items:center;gap:6px;padding:4px 2px 2px;";
const sliderLabel = document.createElement("span");
sliderLabel.textContent = "Point Size";
sliderLabel.style.cssText = "font-size:11px;color:#cdd6f4;white-space:nowrap;flex-shrink:0;";
const slider = document.createElement("input");
slider.type = "range";
slider.min = "0.5";
slider.max = "3.0";
slider.step = "0.1";
slider.value = "1.0";
slider.style.cssText = "flex:1;accent-color:#4499ff;cursor:pointer;";
const sliderVal = document.createElement("span");
sliderVal.textContent = "1.0";
sliderVal.style.cssText = "font-size:11px;color:#cdd6f4;width:28px;text-align:right;flex-shrink:0;";
slider.addEventListener("input", () => {
node._rigPointSize = parseFloat(slider.value);
sliderVal.textContent = parseFloat(slider.value).toFixed(1);
drawNodeCanvas(node);
});
sliderWrap.append(sliderLabel, slider, sliderVal);
// ---- 背景コントロール行 ----
const bgWrap = document.createElement("div");
bgWrap.style.cssText = "display:flex;align-items:center;gap:6px;padding:2px 2px 0;flex-wrap:wrap;";
// 背景色ピッカー
const bgColorLabel = document.createElement("span");
bgColorLabel.textContent = "BG";
bgColorLabel.style.cssText = "font-size:11px;color:#cdd6f4;white-space:nowrap;flex-shrink:0;";
const bgColorPick = document.createElement("input");
bgColorPick.type = "color";
bgColorPick.value = "#e0e0e0";
bgColorPick.title = t("bgColorTooltip");
bgColorPick.style.cssText = "width:28px;height:22px;padding:0;border:1px solid #45475a;border-radius:3px;cursor:pointer;background:none;";
bgColorPick.addEventListener("input", () => {
node._bgColor = bgColorPick.value;
node._bgColorEnabled = true;
node._bgImage = null;
bgImgBtn.textContent = t("bgImageBtn");
drawNodeCanvas(node);
});
// 背景色クリアボタン
const bgColorClear = document.createElement("button");
bgColorClear.textContent = "✕";
bgColorClear.title = t("clearBgColorTooltip");
bgColorClear.style.cssText = "padding:1px 5px;font-size:10px;background:#313244;border:1px solid #45475a;border-radius:3px;color:#cdd6f4;cursor:pointer;";
bgColorClear.addEventListener("click", () => {
node._bgColorEnabled = false;
drawNodeCanvas(node);
});
// 区切り
const bgSep = document.createElement("span");
bgSep.textContent = "|";
bgSep.style.cssText = "color:#45475a;font-size:11px;flex-shrink:0;";
// 背景画像ボタン(ファイルピッカー)
const bgImgInput = document.createElement("input");
bgImgInput.type = "file";
bgImgInput.accept = "image/*";
bgImgInput.style.cssText = "display:none;";
const bgImgBtn = document.createElement("button");
bgImgBtn.textContent = t("bgImageBtn");
bgImgBtn.title = t("loadBgImageTooltip");
bgImgBtn.style.cssText = "padding:2px 8px;font-size:11px;background:#313244;border:1px solid #45475a;border-radius:3px;color:#cdd6f4;cursor:pointer;";
bgImgBtn.addEventListener("click", () => bgImgInput.click());
bgImgInput.addEventListener("change", e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => {
node._bgImage = img;
node._bgColorEnabled = false;
bgImgBtn.textContent = `🖼 ${file.name.slice(0, 12)}`;
drawNodeCanvas(node);
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
e.target.value = "";
});
// 背景画像クリアボタン
const bgImgClear = document.createElement("button");
bgImgClear.textContent = "✕";
bgImgClear.title = t("clearBgImageTooltip");
bgImgClear.style.cssText = "padding:1px 5px;font-size:10px;background:#313244;border:1px solid #45475a;border-radius:3px;color:#cdd6f4;cursor:pointer;";
bgImgClear.addEventListener("click", () => {
node._bgImage = null;
bgImgBtn.textContent = t("bgImageBtn");
drawNodeCanvas(node);
});
// 接続インジケーター(background_image 入力がリンクされているときに表示)
const bgLinkEl = document.createElement("span");
bgLinkEl.style.cssText = "font-size:10px;color:#44cc44;white-space:nowrap;display:none;margin-left:auto;";
bgLinkEl.textContent = t("externalConnected");
bgWrap.append(bgColorLabel, bgColorPick, bgColorClear, bgSep, bgImgInput, bgImgBtn, bgImgClear, bgLinkEl);
// 接続状態の変化で UI 更新
const _origOnCon = node.onConnectionsChange;
node.onConnectionsChange = function (...args) {
if (_origOnCon) _origOnCon.apply(this, args);
const linked = !!(node.inputs?.find(i => i.name === 'background_image')?.link);
bgLinkEl.style.display = linked ? "inline" : "none";
drawNodeCanvas(node);
};
wrap.append(previewBox, statusEl, sliderWrap, bgWrap);
// カメラ初期化
node._camera = { x: 0, y: 0, zoom: 1 };
node._defaultCamera = { x: 0, y: 0, zoom: 1 };
node._nodeCanvas = canvas;
node._previewStatusEl = statusEl;
node._rigPointSize = 1.0;
node._bgColorEnabled = false;
node._bgColor = "#e0e0e0";
node._bgImage = null;
// チェッカー初期表示
drawNodeCanvas(node);
// パン/ズーム操作登録
setupPreviewInteraction(canvas, node);
return wrap;
}
// ================================================
// ComfyUI 拡張登録
// ================================================
app.registerExtension({
name: "psd_loader.PSDFigureCreator",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_TYPE) return;
const _origComputeSize = nodeType.prototype.computeSize;
nodeType.prototype.computeSize = function (out) {
const size = _origComputeSize
? _origComputeSize.call(this, out)
: (out || new Float32Array(2));
size[0] = PREVIEW_NODE_W;
const kfExtra = (this._kfPanelVisible ? KF_PANEL_H : 0);
const minH = UI_WIDGET_H + kfExtra + 80;
if (size[1] < minH) size[1] = minH;
return size;
};
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origCreated ? origCreated.apply(this, arguments) : undefined;
const node = this;
ensureCSS();
hideWidget(node, "psd_filename");
hideWidget(node, "layer_config");
hideWidget(node, "image_data");
// ---- 統合UIコンテナ ----
const uiWrap = document.createElement("div");
uiWrap.style.cssText = `width:100%;box-sizing:border-box;display:flex;flex-direction:column;`;
const btnStyle = [
"background:#313244",
"border:1px solid #45475a",
"border-radius:4px",
"color:#cdd6f4",
"padding:4px 8px",
"cursor:pointer",
"font-size:12px",
"line-height:1.4",
"transition:background .15s",
].join(";");
// ---- 行1: psd + psd model + 更新 ----
// ---- 行2: ライブラリPH / ポーズ保存PH / Setup / 🏷 / RP / RC ----
const row2 = document.createElement("div");
row2.style.cssText = "display:flex;gap:4px;padding:2px 6px 0;box-sizing:border-box;width:100%;height:30px;flex-shrink:0;";
const libBtn = document.createElement("button");
libBtn.style.cssText = btnStyle + ";flex-shrink:0;width:40px;height:26px;padding:1px;overflow:hidden;";
libBtn.title = t("openLibraryTooltip");
{
const lc = document.createElement("canvas");
lc.width = 36; lc.height = 22;
lc.style.cssText = "display:block;border-radius:2px;pointer-events:none;";
const lx = lc.getContext("2d");
lx.fillStyle = "#1a1a2a"; lx.fillRect(0, 0, 36, 22);
const libColors = ["#e05555", "#50a8e0", "#55c855", "#e0b040", "#9055e0", "#40c8c0"];
const cw = 11, ch = 9;
libColors.forEach((c, i) => {
lx.fillStyle = c;
lx.fillRect(1 + (i % 3) * (cw + 1), 2 + Math.floor(i / 3) * (ch + 1), cw, ch);
});
libBtn.appendChild(lc);
}
libBtn.onclick = () => new LibraryModal(node).open();
// ---- ポーズ保存ボタン(クリック: 保存 / 右クリック: 適用) ----
const poseSnapCanvas = document.createElement("canvas");
poseSnapCanvas.width = 36; poseSnapCanvas.height = 22;
poseSnapCanvas.style.cssText = "display:block;border-radius:2px;pointer-events:none;";
const poseSnapCtx = poseSnapCanvas.getContext("2d");
const _refreshPoseSnapThumb = (imgSrc) => {
poseSnapCtx.clearRect(0, 0, 36, 22);
if (imgSrc) {
const img = new Image();
img.onload = () => poseSnapCtx.drawImage(img, 0, 0, 36, 22);
img.src = imgSrc;
} else {
// 暗い背景 + 人型シルエット
poseSnapCtx.fillStyle = "#1a1a2a";
poseSnapCtx.fillRect(0, 0, 36, 22);
poseSnapCtx.fillStyle = "rgba(160,180,210,0.75)";
const cx = 18;
// 頭
poseSnapCtx.beginPath(); poseSnapCtx.arc(cx, 5, 2.8, 0, Math.PI * 2); poseSnapCtx.fill();
// 胴体
poseSnapCtx.fillRect(cx - 2.5, 8.2, 5, 4.5);
// 腕
poseSnapCtx.fillRect(cx - 5.5, 8.5, 3, 2); poseSnapCtx.fillRect(cx + 2.5, 8.5, 3, 2);
// 脚
poseSnapCtx.fillRect(cx - 2.5, 12.8, 2, 4); poseSnapCtx.fillRect(cx + 0.5, 12.8, 2, 4);
}
};
_refreshPoseSnapThumb(null);
const poseSnapBtn = document.createElement("button");
poseSnapBtn.style.cssText = btnStyle + ";flex-shrink:0;width:40px;height:26px;padding:1px;overflow:hidden;";
poseSnapBtn.title = t("poseSaveTooltip");
poseSnapBtn.appendChild(poseSnapCanvas);
node._poseSnapshots = [];
poseSnapBtn.addEventListener("click", async () => {
const poseName = prompt(t("promptPoseName"));
if (!poseName || !poseName.trim()) return;
let config = {};
try { config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
const snap = {
visibility: JSON.parse(JSON.stringify(config.visibility || {})),
pose: JSON.parse(JSON.stringify(config.pose || {})),
thumbnail: null,
};
if (node._nodeCanvas) {
snap.thumbnail = captureThumbFromNode(node, 140);
}
node._poseSnapshots = [snap];
try {
const res = await fetch("/psd_loader/library/poses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: poseName.trim(), content: snap }),
});
const d = await res.json();
if (d.error) throw new Error(d.error);
poseSnapBtn.style.outline = "2px solid #44ff88";
setTimeout(() => { poseSnapBtn.style.outline = "none"; }, 600);
} catch (e) { alert(t("poseSaveFailed", e.message)); }
});
poseSnapBtn.addEventListener("contextmenu", async e => {
e.preventDefault();
const poseName = prompt(t("promptPoseNameWithSw"));
if (!poseName || !poseName.trim()) return;
let config = {};
try { config = JSON.parse(findWidget(node, "layer_config")?.value || "{}"); } catch (_) {}
const swAngles = {};
(config.sw_layers || []).forEach(swl => {
(swl.points || []).forEach(pt => { swAngles[pt.id] = pt.angle ?? 0; });
});
const pswAngles = {};
(config.psw_layers || []).forEach(pswl => {
(pswl.points || []).forEach(pt => { pswAngles[pt.id] = pt.angle ?? 0; });
});
const snap = {
visibility: JSON.parse(JSON.stringify(config.visibility || {})),
pose: JSON.parse(JSON.stringify(config.pose || {})),
sw_angles: swAngles,
psw_angles: pswAngles,
thumbnail: null,
};
if (node._nodeCanvas) {
snap.thumbnail = captureThumbFromNode(node, 140);
}
node._poseSnapshots = [snap];
try {
const res = await fetch("/psd_loader/library/poses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: poseName.trim(), content: snap }),
});
const d = await res.json();
if (d.error) throw new Error(d.error);
poseSnapBtn.style.outline = "2px solid #ff8844";
setTimeout(() => { poseSnapBtn.style.outline = "none"; }, 600);
} catch (e) { alert(t("poseSaveFailed", e.message)); }
});
const setupBtn = document.createElement("button");
setupBtn.style.cssText = btnStyle + ";flex:1;min-width:0;text-align:center;";
setupBtn.textContent = "Setup";
setupBtn.onclick = () => openLayerModal(node);
node._showRigLabels = false;
const nodeLblBtn = document.createElement("button");
nodeLblBtn.style.cssText = btnStyle + ";flex-shrink:0;padding:4px 8px;";
nodeLblBtn.textContent = "🏷";
nodeLblBtn.title = t("toggleLabelsTooltip");
nodeLblBtn.onclick = () => {
node._showRigLabels = !node._showRigLabels;
nodeLblBtn.style.outline = node._showRigLabels ? "2px solid #44ff88" : "none";
drawNodeCanvas(node);
};
node._nodeLblBtn = nodeLblBtn;
const rpBtn = document.createElement("button");
rpBtn.style.cssText = btnStyle + ";flex-shrink:0;padding:4px 8px;";
rpBtn.textContent = "RP";
rpBtn.title = t("resetPoseNodeTooltip");
rpBtn.onclick = () => resetNodePose(node);
const rcBtn = document.createElement("button");
rcBtn.style.cssText = btnStyle + ";flex-shrink:0;padding:4px 10px;";
rcBtn.textContent = "RC";
rcBtn.title = t("resetCameraTooltip");
rcBtn.onclick = () => resetNodeCamera(node);
row2.append(libBtn, poseSnapBtn, setupBtn, nodeLblBtn, rpBtn, rcBtn);
// ---- 行3: Capture + KF パネルトグル ----
const row3 = document.createElement("div");
row3.style.cssText = "display:flex;gap:4px;padding:2px 6px 0;box-sizing:border-box;width:100%;height:30px;flex-shrink:0;";
const captureBtn = document.createElement("button");
captureBtn.style.cssText = btnStyle + ";flex:1;text-align:center;background:#1a4a2a;border-color:#2a7a4a;";
captureBtn.textContent = t("captureBtn");
captureBtn.title = t("captureBtnTooltip");
captureBtn.onclick = async () => {
const ok = captureNode(node);
if (!ok) return;
captureBtn.textContent = t("queuing");
captureBtn.style.background = "#28a745";
captureBtn.disabled = true;
try {
await app.queuePrompt(0, 1);
} catch (e) {
console.warn("[PSDFigureCreator] queuePrompt error:", e);
}
captureBtn.textContent = t("done");
setTimeout(() => {
captureBtn.textContent = t("captureBtn");
captureBtn.style.background = "#1a4a2a";
captureBtn.disabled = false;
}, 1800);
};
// ---- PSW トグルボタン ----
node._pswEnabled = true;
const pswToggleBtn = document.createElement("button");
const updatePswToggle = () => {
const on = node._pswEnabled;
pswToggleBtn.textContent = "PSW";
pswToggleBtn.style.cssText = btnStyle
+ `;flex-shrink:0;padding:4px 8px;`
+ `background:${on ? "#0a1e4a" : "#4a0a0a"};`
+ `border-color:${on ? "#3366cc" : "#cc3333"};`
+ `color:${on ? "#88aaff" : "#ff6666"};`
+ `font-weight:bold;`;
pswToggleBtn.title = on ? "PSW ON — クリックでOFF(R/MR自由操作モード)" : "PSW OFF — クリックでON(プリセットポーズモード)";
};
node._updatePswToggle = updatePswToggle;
pswToggleBtn.onclick = () => {
node._pswEnabled = !node._pswEnabled;
updatePswToggle();
const _lw = findWidget(node, "layer_config");
if (_lw) {
let _cfg = {};
try { _cfg = JSON.parse(_lw.value || "{}"); } catch (_) {}
_cfg.pswEnabled = node._pswEnabled;
_lw.value = JSON.stringify(_cfg);
}
app.graph?.setDirtyCanvas(true, true);
};
updatePswToggle();
// ---- キーフレームパネルトグルボタン ----
node._kfPanelVisible = false;
node._keyframes = [];
node._kfCurrentFrame = 0;
node._kfTotalFrames = 60;
node._kfFps = 24;
node._kfPlaying = false;
node._kfPlayRaf = null;
const kfToggleBtn = document.createElement("button");
kfToggleBtn.style.cssText = btnStyle + ";flex-shrink:0;padding:4px 8px;";
kfToggleBtn.textContent = t("kfPanelBtn");
kfToggleBtn.title = t("kfPanelTooltip");
// ---- キーフレームパネル ----
const kfPanel = buildKeyframePanel(node);
kfToggleBtn.onclick = () => {
node._kfPanelVisible = !node._kfPanelVisible;
kfPanel.style.display = node._kfPanelVisible ? "flex" : "none";
kfToggleBtn.style.outline = node._kfPanelVisible ? "2px solid #aa88ff" : "none";
node.size[1] = node.computeSize()[1];
app.graph?.setDirtyCanvas(true, true);
};
row3.append(pswToggleBtn, captureBtn, kfToggleBtn);
// ---- プレビュー ----
const previewWrap = buildPreviewWidget(node);
uiWrap.append(row2, row3, kfPanel, previewWrap);
node.addDOMWidget("psd_ui", "customtext", uiWrap, {
getValue() { return ""; },
setValue() {},
computeSize(w) {
return [w, UI_WIDGET_H + (node._kfPanelVisible ? KF_PANEL_H : 0)];
},
});
// ---- output_width / output_height 変更時にフレームを即再描画 ----
for (const wname of ["output_width", "output_height"]) {
const w = findWidget(node, wname);
if (w) {
const origCb = w.callback;
w.callback = function (...args) {
origCb?.apply(this, args);
drawNodeCanvas(node);
};
}
}
// ---- ファイル名復元 ----
const fname = findWidget(node, "psd_filename")?.value;
if (fname) {
node._psdFilename = fname;
if (node._psdBtn) node._psdBtn.textContent = `📂 ${fname}`;
}
// ---- ノードサイズ ----
node.resizable = false;
node.size[0] = PREVIEW_NODE_W;
node.size[1] = node.computeSize()[1];
node.onResize = function () {
this.size[0] = PREVIEW_NODE_W;
this.size[1] = this.computeSize()[1];
};
return r;
};
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
if (origConfigure) origConfigure.apply(this, arguments);
hideWidget(this, "psd_filename");
hideWidget(this, "layer_config");
hideWidget(this, "image_data");
this.resizable = false;
this.size[0] = PREVIEW_NODE_W;
this.size[1] = this.computeSize()[1];
const node = this;
// キーフレーム状態を layer_config から復元
const lw = findWidget(node, "layer_config");
if (lw) {
let cfg = {};
try { cfg = JSON.parse(lw.value || "{}"); } catch (_) {}
if (Array.isArray(cfg.keyframes) && cfg.keyframes.length > 0) {
node._keyframes = JSON.parse(JSON.stringify(cfg.keyframes));
updateTimelineCanvas(node);
}
if (cfg.kf_total_frames !== undefined) {
node._kfTotalFrames = cfg.kf_total_frames;
if (node._kfTotalFramesEl) node._kfTotalFramesEl.value = cfg.kf_total_frames;
}
if (cfg.kf_fps !== undefined) {
node._kfFps = cfg.kf_fps;
if (node._kfFpsEl) node._kfFpsEl.value = cfg.kf_fps;
}
if (cfg.pswEnabled !== undefined) {
node._pswEnabled = cfg.pswEnabled;
if (node._updatePswToggle) node._updatePswToggle();
}
}
const fname = findWidget(node, "psd_filename")?.value;
if (fname) {
node._psdFilename = fname;
if (node._psdBtn) node._psdBtn.textContent = `📂 ${fname}`;
(async () => {
try {
const res = await fetch(`/psd_loader/layers?filename=${encodeURIComponent(fname)}`);
const data = await res.json();
if (data.error) return;
node._psdLayers = data.layers;
node._psdW = data.width;
node._psdH = data.height;
setDefaultCamera(node);
await refreshNodePreview(node);
} catch (_) {}
})();
}
};
const origRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
if (origRemoved) origRemoved.apply(this, arguments);
stopPlayback(this);
if (this._layerImages) {
for (const entry of Object.values(this._layerImages)) {
if (entry.objUrl) URL.revokeObjectURL(entry.objUrl);
}
}
};
},
});