Files
ketle-manandClaude Sonnet 5.5 8eb068aea5 v1.53.2: マニュアル執筆中に見つかった改善候補20件への対応
- 初回起動時にA4縦・A4横の3コマテンプレートを自動登録、「ページ新規作成」を「クイック」に改称(線幅の確認を省略)
- 出力タブ: 終了ページを総ページ数に自動設定、作業中の作品で自動絞り込み、PDF/EPUB選択時に「背景のみ」「zip保存」を無効化、「フキダシのみ」を削除
- レイアウトタブ「画像」サブタブ: BiRefNet背景除去を実装、実行結果を元画像の位置・大きさで挿入し元画像を非表示に
- Nanobanana: 複数枚生成時に全結果をサムネイル表示し、選んだ画像をコマへ挿入
- Imageタブ: トースト・ダイアログの多言語化(ja/en/zh)、テキスト入力をレイアウトタブと同じ操作(Ctrl+Enter確定)に統一
- 初回の表示言語をブラウザの言語から決定、Eagle設定の「ComfyUI 生成画像を自動保存」はWorkflow Studio側設定を案内(グレーアウト)
- My曲線の既定画像を同梱、ヘルプ(ja/en/zh)の表記修正、.comfyignoreに!docs/manual/を追加
- 起動ログのポート表示を固定値から実際のポートに修正

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:40:10 +09:00

527 lines
23 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.
// ============================================================
// main.js 分割ファイル (17/24): Processingタブ+Editタブ
// 元 main.js の行 13258-13628 に相当
// type="module" として読み込まれる(ESモジュール化 G7)。
// 主なトップレベル定義: _getSvgSize,_layerDrawOriginalUnit,_procApplyDenoise,_procApplySharpen,_procRemoveBackground,_procRemoveBackgroundBiRefNet,_procRemoveBackgroundImgly,_procRemoveBackgroundSvg,_procRun,_procState,_procUpscale,_svgBase64ToText,initEditTab,initProcessingTab
// 未ESM化の外部依存(非moduleのグローバル関数はwindowプロパティとして自動的に見えるため、
// 呼び出し箇所は書き換えていない): state(01-state.js)
// ============================================================
import { t } from '../i18n.js';
import { insertImage } from './08-panels-images.js';
import { _layerDrawState, initLayerDraw, _layerDrawUpdateToggle, _layerDrawDetachOverlay } from './17a-layer-draw-input.js';
import { _paintToolState, initPaintTool, _paintUpdateToggle, _paintDetachOverlay } from './17d-layer-draw-paint.js';
import { _layerDrawShapeToPng } from './17b-layer-draw-commit.js';
import { _svgColorState, _svgColorLoad, _svgColorApply, _svgToPngConvert } from './18-svg-color-png.js';
import { openImageTabWithSelected, openLayoutI2IModal, moveSelectedObjectToCenter } from './15-pixifx-bridge.js';
import { state } from './01-state.js';
import { getPanelLayerSvg, renderLayerPanel } from './04b-layer-panel-render.js';
import { savePanelSvg } from './07-pages.js';
// ============================================================
// Processing タブ(アップスケール・背景除去)
// ============================================================
const _procState = {
scale: 2.0,
};
function initProcessingTab() {
const scaleUp = document.getElementById('proc-scale-up');
const scaleDown = document.getElementById('proc-scale-down');
const scaleVal = document.getElementById('proc-scale-value');
const sharpen = document.getElementById('proc-sharpen');
const sharpenV = document.getElementById('proc-sharpen-value');
const runBtn = document.getElementById('proc-run-btn');
scaleUp.addEventListener('click', () => {
_procState.scale = Math.min(4.0, parseFloat((_procState.scale + 0.5).toFixed(1)));
scaleVal.textContent = _procState.scale.toFixed(1) + '×';
});
scaleDown.addEventListener('click', () => {
_procState.scale = Math.max(1.0, parseFloat((_procState.scale - 0.5).toFixed(1)));
scaleVal.textContent = _procState.scale.toFixed(1) + '×';
});
sharpen.addEventListener('input', () => {
sharpenV.textContent = sharpen.value + '%';
});
const bgRemoveChk = document.getElementById('proc-bg-remove');
const bgModelSelect = document.getElementById('proc-bg-model');
const bgQualitySelect = document.getElementById('proc-bg-quality');
const syncBgUI = () => {
const on = bgRemoveChk.checked;
bgModelSelect.style.display = on ? 'inline-block' : 'none';
bgQualitySelect.style.display = (on && bgModelSelect.value === 'imgly') ? 'inline-block' : 'none';
};
bgRemoveChk.addEventListener('change', syncBgUI);
bgModelSelect.addEventListener('change', syncBgUI);
_procCheckBiRefNetAvailability(bgModelSelect);
runBtn.addEventListener('click', _procRun);
}
async function _procRun() {
const statusEl = document.getElementById('proc-status');
const bgRemove = document.getElementById('proc-bg-remove').checked;
const bgModel = document.getElementById('proc-bg-model').value;
const bgQuality = document.getElementById('proc-bg-quality').value;
const denoise = document.getElementById('proc-denoise').checked;
const sharpenPc = parseInt(document.getElementById('proc-sharpen').value, 10) / 100;
// 選択画像の base64 取得
if (!state.selectedImageEl) {
alert(t('layout.msgSelectImage'));
return;
}
const origHref = state.selectedImageEl.getAttribute('href') || state.selectedImageEl.getAttribute('xlink:href') || '';
if (!origHref.startsWith('data:')) {
alert(t('layout.msgImageBase64Unavailable'));
return;
}
// 元画像の位置・表示サイズ・回転を引き継いで挿入し、成功後に元画像を非表示にする
const origEl = state.selectedImageEl;
const origId = origEl.id;
const num = (n) => parseFloat(origEl.getAttribute(n));
const placement = [num('x'), num('y'), num('width'), num('height')].every(Number.isFinite)
? { x: num('x'), y: num('y'), width: num('width'), height: num('height') }
: null;
const extraAttrs = {};
for (const a of ['transform', 'preserveAspectRatio']) {
const v = origEl.getAttribute(a);
if (v) extraAttrs[a] = v;
}
statusEl.textContent = t('layout.processing');
document.getElementById('proc-run-btn').disabled = true;
try {
let dataUrl = origHref;
const isSvg = dataUrl.startsWith('data:image/svg');
// SVGの場合: 背景除去のみ実行してアップスケール等をスキップ
if (isSvg) {
if (bgRemove) {
statusEl.textContent = t('layout.procSvgBgRemoving');
dataUrl = _procRemoveBackgroundSvg(dataUrl);
}
statusEl.textContent = t('layout.procInserting');
const { w, h } = _getSvgSize(dataUrl);
await insertImage(dataUrl, w, h, extraAttrs, placement);
await _procHideOriginal(origId);
statusEl.textContent = t('common.done');
setTimeout(() => { statusEl.textContent = ''; }, 3000);
return;
}
// 背景除去
if (bgRemove) {
statusEl.textContent = t('layout.procBgRemoving', bgModel === 'birefnet' ? 'BiRefNet' : t('layout.procLightModel'));
dataUrl = await _procRemoveBackground(dataUrl, bgModel, bgQuality);
}
// アップスケール
statusEl.textContent = t('image.upscaleRunning');
dataUrl = await _procUpscale(dataUrl, _procState.scale, denoise, sharpenPc);
// 新規保存(insertImage でコマに挿入)
statusEl.textContent = t('layout.procInserting');
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = dataUrl;
});
await insertImage(dataUrl, img.width, img.height, extraAttrs, placement);
await _procHideOriginal(origId);
statusEl.textContent = t('common.done');
setTimeout(() => { statusEl.textContent = ''; }, 3000);
} catch (e) {
console.error('Processing error:', e);
statusEl.textContent = t('common.errorPrefix', e.message);
} finally {
document.getElementById('proc-run-btn').disabled = false;
}
}
// 実行後に元画像を非表示にして保存する(レイヤーパネルの👁ボタンと同じ状態。再表示も👁ボタンで可能)
async function _procHideOriginal(origId) {
if (!origId) return;
const svg = getPanelLayerSvg();
const el = svg && Array.from(svg.querySelectorAll('.inserted-image')).find(e => e.id === origId);
if (!el) return;
el.style.display = 'none';
const panelId = el.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') ||
el.getAttribute('data-panel-id') || state.selectedPanelId || 'panel-0';
await savePanelSvg(panelId, svg);
if (state.selectedImageId === origId) { state.selectedImageId = null; state.selectedImageEl = null; }
renderLayerPanel();
}
// base64 SVG dataUrl を UTF-8 テキストにデコードするヘルパー
function _svgBase64ToText(dataUrl) {
const raw = dataUrl.split(',')[1] || '';
const bytes = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
// SVGのbase64 dataUrlからviewBox/width/heightを取得して { w, h } を返す
function _getSvgSize(dataUrl) {
try {
const svgText = _svgBase64ToText(dataUrl);
const parser = new DOMParser();
const svgEl = parser.parseFromString(svgText, 'image/svg+xml').querySelector('svg');
if (svgEl) {
const vb = svgEl.getAttribute('viewBox');
if (vb) {
const p = vb.trim().split(/[\s,]+/);
return { w: parseFloat(p[2]) || 1000, h: parseFloat(p[3]) || 1000 };
}
return {
w: parseFloat(svgEl.getAttribute('width')) || 1000,
h: parseFloat(svgEl.getAttribute('height')) || 1000,
};
}
} catch (_) {}
return { w: 1000, h: 1000 };
}
// SVG背景除去: 単色背景のrect/矩形pathを削除し、透過SVGとして返す
function _procRemoveBackgroundSvg(dataUrl) {
try {
const svgText = _svgBase64ToText(dataUrl);
const parser = new DOMParser();
const doc = parser.parseFromString(svgText, 'image/svg+xml');
const svgEl = doc.querySelector('svg');
if (!svgEl) return dataUrl;
// 全面を覆うrect(原点付近から全幅・全高、任意色)を削除
const vb = svgEl.getAttribute('viewBox');
const vbParts = vb ? vb.trim().split(/[\s,]+/) : null;
const vbW = vbParts ? parseFloat(vbParts[2]) : null;
const vbH = vbParts ? parseFloat(vbParts[3]) : null;
svgEl.querySelectorAll('rect').forEach(el => {
const x = parseFloat(el.getAttribute('x') || '0');
const y = parseFloat(el.getAttribute('y') || '0');
const w = el.getAttribute('width') || '';
const h = el.getAttribute('height') || '';
const isOrigin = x <= 0 && y <= 0;
const isFullW = w === '100%' || (vbW && parseFloat(w) >= vbW * 0.99);
const isFullH = h === '100%' || (vbH && parseFloat(h) >= vbH * 0.99);
if (isOrigin && isFullW && isFullH) el.remove();
});
const serializer = new XMLSerializer();
const newSvg = serializer.serializeToString(svgEl);
return 'data:image/svg+xml;base64,' + btoa(new TextEncoder().encode(newSvg).reduce((s, b) => s + String.fromCharCode(b), ''));
} catch (e) {
console.error('[SVG BgRemove]', e);
return dataUrl;
}
}
async function _procRemoveBackground(dataUrl, model = 'imgly', quality = 'medium') {
if (model === 'birefnet') {
return await _procRemoveBackgroundBiRefNet(dataUrl);
}
return await _procRemoveBackgroundImgly(dataUrl, quality);
}
// quality: 'small'(軽量・低品質、isnet_quint8)/ 'medium'(標準、isnet_fp16、既定)/ 'large'(高品質、isnet)
async function _procRemoveBackgroundImgly(dataUrl, quality = 'medium') {
if (!window._imglyRemoveBackground) {
const mod = await import('https://esm.sh/@imgly/background-removal@1.5.7?bundle&target=es2022');
window._imglyRemoveBackground = mod.removeBackground;
}
const fn = window._imglyRemoveBackground;
const res = await fetch(dataUrl);
const blob = await res.blob();
const resultBlob = await fn(blob, {
publicPath: 'https://staticimgly.com/@imgly/background-removal-data/1.5.7/dist/',
model: quality,
});
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(resultBlob);
});
}
// BiRefNet 背景除去(Mask Editor One の /mask_editor/birefnet/* ルートを利用。Imageタブと同じ経路)
async function _procRemoveBackgroundBiRefNet(dataUrl) {
const NODE_ID = 'cc_layout_proc_bgremove';
const storeResp = await fetch('/mask_editor/store_image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
});
if (!storeResp.ok) throw new Error('Failed to cache image for BiRefNet');
const resp = await fetch('/mask_editor/birefnet/remove_bg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: NODE_ID }),
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
return await _procApplyMaskToImage(dataUrl, json.mask_b64);
}
// グレースケールマスク(白=前景)を元画像に destination-in で適用してRGBA PNGを返す
async function _procApplyMaskToImage(imageB64, maskB64) {
const loadImage = src => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image load failed'));
img.src = src;
});
const [origImg, maskImg] = await Promise.all([loadImage(imageB64), loadImage(maskB64)]);
const w = origImg.naturalWidth;
const h = origImg.naturalHeight;
const maskCanvas = document.createElement('canvas');
maskCanvas.width = w;
maskCanvas.height = h;
const mc = maskCanvas.getContext('2d');
mc.drawImage(maskImg, 0, 0, w, h);
const maskData = mc.getImageData(0, 0, w, h);
const md = maskData.data;
for (let i = 0; i < md.length; i += 4) {
md[i + 3] = md[i]; // 輝度 → アルファ
md[i] = md[i + 1] = md[i + 2] = 255;
}
mc.putImageData(maskData, 0, 0);
const out = document.createElement('canvas');
out.width = w;
out.height = h;
const ctx = out.getContext('2d');
ctx.drawImage(origImg, 0, 0, w, h);
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(maskCanvas, 0, 0, w, h);
return out.toDataURL('image/png');
}
// BiRefNet の利用可否を確認し、利用できない場合は選択肢を無効化して理由をラベルに表示する
async function _procCheckBiRefNetAvailability(bgModelSelect) {
const opt = bgModelSelect.querySelector('option[value="birefnet"]');
if (!opt) return;
let available = false;
try {
const resp = await fetch('/mask_editor/birefnet/status');
if (resp.ok) {
const json = await resp.json();
available = json.loaded === true || json.model_found === true;
}
} catch (_) { /* Mask Editor One 未導入 */ }
opt.disabled = !available;
const key = available ? 'layout.bgModelBiRefNet' : 'layout.bgModelBiRefNetUnavailable';
opt.setAttribute('data-i18n', key);
opt.textContent = t(key);
if (!available && bgModelSelect.value === 'birefnet') bgModelSelect.value = 'imgly';
bgModelSelect.dispatchEvent(new Event('change'));
}
async function _procUpscale(dataUrl, scale, denoise, sharpenRatio) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const w = img.width;
const h = img.height;
// ソースキャンバス
const srcCanvas = document.createElement('canvas');
srcCanvas.width = w;
srcCanvas.height = h;
const srcCtx = srcCanvas.getContext('2d');
srcCtx.drawImage(img, 0, 0);
let srcData = srcCtx.getImageData(0, 0, w, h);
// ノイズ除去
if (denoise) {
srcData = _procApplyDenoise(srcData, w, h);
srcCtx.putImageData(srcData, 0, 0);
}
// アップスケール(バイキュービック相当)
const dstW = Math.round(w * scale);
const dstH = Math.round(h * scale);
const dstCanvas = document.createElement('canvas');
dstCanvas.width = dstW;
dstCanvas.height = dstH;
const dstCtx = dstCanvas.getContext('2d');
dstCtx.imageSmoothingEnabled = true;
dstCtx.imageSmoothingQuality = 'high';
dstCtx.drawImage(srcCanvas, 0, 0, dstW, dstH);
// シャープ化
if (sharpenRatio > 0) {
const sharpData = _procApplySharpen(dstCtx.getImageData(0, 0, dstW, dstH), dstW, dstH, sharpenRatio);
dstCtx.putImageData(sharpData, 0, 0);
}
resolve(dstCanvas.toDataURL('image/png'));
};
img.onerror = reject;
img.src = dataUrl;
});
}
function _procApplyDenoise(imageData, w, h) {
const src = imageData.data;
const out = new Uint8ClampedArray(src.length);
const radius = 2;
const sigma = 30;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let rSum = 0, gSum = 0, bSum = 0, wSum = 0;
const ci = (y * w + x) * 4;
const cr = src[ci], cg = src[ci+1], cb = src[ci+2];
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
const ni = (ny * w + nx) * 4;
const nr = src[ni], ng = src[ni+1], nb = src[ni+2];
const spatialW = Math.exp(-(dx*dx + dy*dy) / (2 * radius * radius));
const colorD = (nr-cr)**2 + (ng-cg)**2 + (nb-cb)**2;
const colorW = Math.exp(-colorD / (2 * sigma * sigma));
const w2 = spatialW * colorW;
rSum += nr * w2; gSum += ng * w2; bSum += nb * w2; wSum += w2;
}
}
const oi = (y * w + x) * 4;
out[oi] = rSum / wSum;
out[oi+1] = gSum / wSum;
out[oi+2] = bSum / wSum;
out[oi+3] = src[oi+3];
}
}
return new ImageData(out, w, h);
}
function _procApplySharpen(imageData, w, h, amount) {
const src = imageData.data;
const out = new Uint8ClampedArray(src.length);
const kernel = [-1,-1,-1,-1,9,-1,-1,-1,-1];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let r = 0, g = 0, b = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const nx = Math.max(0, Math.min(w-1, x+kx));
const ny = Math.max(0, Math.min(h-1, y+ky));
const ni = (ny * w + nx) * 4;
const k = kernel[(ky+1)*3 + (kx+1)];
r += src[ni] * k;
g += src[ni+1] * k;
b += src[ni+2] * k;
}
}
const oi = (y * w + x) * 4;
const sr = src[oi], sg = src[oi+1], sb = src[oi+2];
out[oi] = Math.max(0, Math.min(255, sr + (r - sr) * amount));
out[oi+1] = Math.max(0, Math.min(255, sg + (g - sg) * amount));
out[oi+2] = Math.max(0, Math.min(255, sb + (b - sb) * amount));
out[oi+3] = src[oi+3];
}
}
return new ImageData(out, w, h);
}
// ============================================================
// Edit タブ(ボックス描画・テキスト・モザイク・ぼかし)
// ============================================================
function initEditTab() {
// Editモード切り替えタブ
document.querySelectorAll('.edit-mode-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.edit-mode-tab-btn').forEach(b => {
b.classList.remove('active');
b.style.background = 'var(--bg-secondary)';
});
btn.classList.add('active');
btn.style.background = 'var(--bg-primary)';
const mode = btn.dataset.editMode;
document.getElementById('edit-panel-box').style.display = mode === 'box' ? '' : 'none';
document.getElementById('edit-panel-paint').style.display = mode === 'paint' ? '' : 'none';
document.getElementById('edit-panel-svg-color').style.display = mode === 'svg-color' ? '' : 'none';
document.getElementById('edit-panel-svg-png').style.display = mode === 'svg-png' ? '' : 'none';
// ドロータブから離れたら描画ONを解除。ペイントタブから離れてもペイントONを解除
if (mode !== 'box' && _layerDrawState.active) {
_layerDrawState.active = false;
_layerDrawUpdateToggle();
_layerDrawDetachOverlay();
}
if (mode !== 'paint' && _paintToolState.active) {
_paintToolState.active = false;
_paintUpdateToggle();
_paintDetachOverlay();
}
});
});
// SVG色変更
document.getElementById('svg-color-load-btn').addEventListener('click', _svgColorLoad);
document.getElementById('svg-color-apply-btn').addEventListener('click', _svgColorApply);
// SVG→PNG変換
document.getElementById('svg-png-convert-btn').addEventListener('click', _svgToPngConvert);
// 縦横比固定: 幅変更で高さを自動更新
const pngW = document.getElementById('svg-png-width');
const pngH = document.getElementById('svg-png-height');
pngW.addEventListener('input', () => {
if (!document.getElementById('svg-png-keep-aspect').checked) return;
const ratio = _svgColorState.origHeight / (_svgColorState.origWidth || 1);
if (pngW.value) pngH.value = Math.round(Number(pngW.value) * ratio) || '';
});
pngH.addEventListener('input', () => {
if (!document.getElementById('svg-png-keep-aspect').checked) return;
const ratio = _svgColorState.origWidth / (_svgColorState.origHeight || 1);
if (pngH.value) pngW.value = Math.round(Number(pngH.value) * ratio) || '';
});
// 図形をPNG変換ボタン
document.getElementById('layer-draw-shape-to-png')?.addEventListener('click', _layerDrawShapeToPng);
// レイヤー描画初期化
initLayerDraw();
// ペイントツール初期化(ラスターブラシ。ドローとは別タブ・別状態で動作)
initPaintTool();
// 画像タブで編集ボタン: Imageタブで開く
document.getElementById('layer-draw-open-imgedit').addEventListener('click', openImageTabWithSelected);
// I2Iボタン: 選択画像またはページ全体をWorkflow Studioへ送信し、その場でI2I実行するモーダルを開く
document.getElementById('layout-i2i-modal-btn')?.addEventListener('click', openLayoutI2IModal);
// OCボタン: 選択中オブジェクトをコマ/ページ中央へ移動
document.getElementById('object-center-btn')?.addEventListener('click', moveSelectedObjectToCenter);
}
// オリジナル画像の一コマをCanvasに描画(レイヤー描画のMy曲線用)
function _layerDrawOriginalUnit(ctx, x, y, angle, scale, img) {
if (!img || !img.complete || img.naturalWidth === 0) return;
const w = img.naturalWidth * scale;
const h = img.naturalHeight * scale;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.drawImage(img, -w / 2, -h / 2, w, h);
ctx.restore();
}
export { initProcessingTab, initEditTab, _layerDrawOriginalUnit };
// まだESM化されていない main/以下の classic <script> から呼べるようにするブリッジ
// (ESモジュール化移行中の一時措置。全分割ファイルのESM化が完了したら、
// 各呼び出し元をimport文に置き換えてこのブロックごと削除する)。
window.initProcessingTab = initProcessingTab;
window.initEditTab = initEditTab;