翻訳機能を追加(チップ下に表示・言語自動検出)

- 各タグチップの2段目に「訳」ボタン+翻訳表示(チップを常時2行化)
- 言語自動検出: 日本語タグ→英訳を表示、英語タグ→何もしない
- バックエンドは Google(deep-translator, APIキー不要)優先 + argostranslate
  (オフライン)フォールバック。エスケープ括弧を除去して翻訳精度向上
- server.py: /prompt_tag_editor/translate ルート、requirements追記

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4thCafe
2026-07-17 03:14:40 +09:00
co-authored by Claude Fable 5
parent 8f57e86770
commit 7b31cb53be
7 changed files with 258 additions and 36 deletions
+1
View File
@@ -18,6 +18,7 @@
- `+` / `−` で**重み調整**(`(word:1.2)` 構文、0.05刻み、1.0付近で括弧自動除去)
- **ドラッグ&ドロップで並べ替え**
- `×` で**削除**
- `訳` で**日本語訳をチップ下に表示**(Google翻訳=要ネット・APIキー不要を優先、失敗時は argostranslate=オフラインへフォールバック)
- **非破壊**: 値はネイティブのテキストウィジェットが保持するため、ワークフロー保存・実行時プロンプトに確実に反映されます。LoRA `<lora:...>`・`BREAK`・エスケープ・括弧内カンマも壊しません。
設定 → `Prompt Tag Editor: タグ編集エリアを表示` でチップエリアのON/OFFができます。
+7
View File
@@ -6,6 +6,13 @@
"""
# 翻訳ルート(/prompt_tag_editor/translate)を登録。失敗してもノード読込は継続。
try:
from . import server as _server # noqa: F401
except Exception as _e: # pragma: no cover
print(f"[PromptTagEditor] translate route not registered: {_e}")
class PromptTagEditorNode:
"""タグ編集用の汎用プロンプトノード。text をそのまま STRING で出力する。
+32 -2
View File
@@ -23,8 +23,8 @@
.pte-chip {
display: inline-flex;
align-items: center;
gap: 3px;
flex-direction: column;
align-items: stretch;
padding: 2px 4px 2px 7px;
background: var(--comfy-menu-bg, #353535);
border: 1px solid var(--border-color, #4a4a4a);
@@ -37,6 +37,36 @@
max-width: 100%;
}
.pte-chip-row {
display: flex;
align-items: center;
gap: 3px;
}
.pte-chip-row2 {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
padding-top: 2px;
border-top: 1px dashed rgba(255, 255, 255, 0.15);
}
.pte-chip-trans {
font-size: 10px;
color: var(--descrip-text, #9aa);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.pte-translate {
font-size: 10px;
font-weight: 700;
flex: 0 0 auto;
}
.pte-chip.pte-dragging {
opacity: 0.45;
}
+76 -34
View File
@@ -1,12 +1,18 @@
// tag_editor.js — チップ(タグ)エリアの描画と操作(編集 / +/- / × / ドラッグ並べ替え)
// tag_editor.js — チップ(タグ)エリアの描画と操作(編集 / +/- / × / ドラッグ並べ替え / 翻訳)
// ComfyUI-PromptTagEditor / 4thCafe
import {
tokenize, serialize, adjustWeight, setTokenText, displayLabel,
} from "./prompt_parser.js";
import { translateTags } from "./translate.js";
const WEIGHT_STEP = 0.05;
// 日本語(ひらがな/カタカナ/漢字/半角カナ)を含むか
function hasJapanese(s) {
return /[぀-ヿ㐀-鿿ヲ-゚]/.test(s || "");
}
export class TagEditor {
/**
* @param {HTMLElement} container チップを描画するコンテナ
@@ -59,42 +65,19 @@ export class TagEditor {
chip.classList.add("pte-weighted");
}
chip.draggable = true;
this._wireDrag(chip, idx);
// ドラッグ並べ替え
chip.addEventListener("dragstart", (e) => {
this._dragIndex = idx;
chip.classList.add("pte-dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", String(idx)); } catch (_) {}
});
chip.addEventListener("dragend", () => {
chip.classList.remove("pte-dragging");
this._dragIndex = -1;
});
chip.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
});
chip.addEventListener("drop", (e) => {
e.preventDefault();
const from = this._dragIndex;
if (from < 0 || from === idx) return;
const [moved] = this.tokens.splice(from, 1);
// from < idx の場合、削除で1つ詰まるため補正
const to = from < idx ? idx - 1 : idx;
this.tokens.splice(to, 0, moved);
this._emitAndRender();
});
// === 1段目: ラベル + 重み/削除ボタン ===
const row = document.createElement("div");
row.className = "pte-chip-row";
// ラベル(ダブルクリックでinline編集)
const label = document.createElement("span");
label.className = "pte-chip-label";
label.textContent = displayLabel(tok);
label.title = tok.raw;
label.addEventListener("dblclick", () => this._startEdit(chip, label, tok));
chip.appendChild(label);
row.appendChild(label);
// 操作ボタン
const btns = document.createElement("span");
btns.className = "pte-chip-btns";
if (tok.type === "tag") {
@@ -114,10 +97,70 @@ export class TagEditor {
this.tokens.splice(idx, 1);
this._emitAndRender();
}));
chip.appendChild(btns);
row.appendChild(btns);
chip.appendChild(row);
// === 2段目: [訳]ボタン + 翻訳テキスト(tagのみ・最初から表示) ===
if (tok.type === "tag") {
const row2 = document.createElement("div");
row2.className = "pte-chip-row2";
const trans = document.createElement("span");
trans.className = "pte-chip-trans";
if (tok.__trans) trans.textContent = tok.__trans;
// 訳ボタンは2段目の頭(将来は記号等へ変更予定)
const tbtn = this._btn("pte-translate", "訳", "翻訳(日本語)", (e) => {
e.stopPropagation();
this._doTranslate(tok, trans);
});
row2.appendChild(tbtn);
row2.appendChild(trans);
chip.appendChild(row2);
}
return chip;
}
_wireDrag(chip, idx) {
chip.addEventListener("dragstart", (e) => {
this._dragIndex = idx;
chip.classList.add("pte-dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", String(idx)); } catch (_) {}
});
chip.addEventListener("dragend", () => {
chip.classList.remove("pte-dragging");
this._dragIndex = -1;
});
chip.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
});
chip.addEventListener("drop", (e) => {
e.preventDefault();
const from = this._dragIndex;
if (from < 0 || from === idx) return;
const [moved] = this.tokens.splice(from, 1);
const to = from < idx ? idx - 1 : idx;
this.tokens.splice(to, 0, moved);
this._emitAndRender();
});
}
async _doTranslate(tok, transEl) {
// 言語自動検出: 日本語を含む→英訳を表示。英語のみ→何もしない。
if (!hasJapanese(tok.text)) {
return;
}
transEl.textContent = "…";
const res = await translateTags([tok.text], "ja", "en");
const v = res && res[0] ? res[0] : null;
if (v) {
tok.__trans = v;
transEl.textContent = v;
} else {
transEl.textContent = "(翻訳失敗)";
}
}
_btn(cls, text, title, onClick) {
const b = document.createElement("button");
b.className = "pte-btn " + cls;
@@ -125,9 +168,8 @@ export class TagEditor {
b.textContent = text;
b.title = title;
b.tabIndex = -1;
b.addEventListener("click", onClick);
// ドラッグがボタンから始まらないように
b.draggable = false;
b.addEventListener("click", onClick);
b.addEventListener("mousedown", (e) => e.stopPropagation());
return b;
}
@@ -149,11 +191,11 @@ export class TagEditor {
if (save) {
const v = input.value.trim();
if (v === "") {
// 空にしたら削除
const i = this.tokens.indexOf(tok);
if (i >= 0) this.tokens.splice(i, 1);
} else {
setTokenText(tok, v);
tok.__trans = undefined; // テキストが変わったら旧翻訳は無効化
}
this._emit();
}
@@ -163,7 +205,7 @@ export class TagEditor {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commit(true); }
else if (e.key === "Escape") { e.preventDefault(); commit(false); }
e.stopPropagation(); // ComfyUIのショートカット抑制
e.stopPropagation();
});
input.addEventListener("blur", () => commit(true));
}
+24
View File
@@ -0,0 +1,24 @@
// translate.js — バックエンド翻訳ルートの呼び出し
// ComfyUI-PromptTagEditor / 4thCafe
import { api } from "../../scripts/api.js";
// tags(文字列配列)を from→to 翻訳して返す。失敗時は null。
export async function translateTags(tags, from = "en", to = "ja") {
try {
const resp = await api.fetchApi("/prompt_tag_editor/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tags, from, to }),
});
if (!resp.ok) {
console.warn("[PromptTagEditor] translate HTTP", resp.status);
return null;
}
const data = await resp.json();
return Array.isArray(data.translations) ? data.translations : null;
} catch (e) {
console.warn("[PromptTagEditor] translate failed:", e);
return null;
}
}
+3
View File
@@ -0,0 +1,3 @@
# 翻訳: Google(要ネット・APIキー不要)を優先、失敗時は argostranslate(オフライン)へフォールバック
deep-translator
argostranslate
+115
View File
@@ -0,0 +1,115 @@
"""翻訳バックエンド。argostranslate(オフライン/API不要)で各タグを翻訳する軽量ルート。
フロントの各チップの「訳」ボタンから
POST /prompt_tag_editor/translate {tags:[...], from:"en", to:"ja"}
で呼ばれ、{translations:[...]} を返す。言語パッケージ未導入時は初回のみ自動DL。
"""
import asyncio
import threading
from aiohttp import web
try:
from server import PromptServer
except Exception: # ComfyUI外(単体テスト等)
PromptServer = None
try:
import argostranslate.package as _pkg
import argostranslate.translate as _tr
_ARGOS_OK = True
except Exception:
_ARGOS_OK = False
_install_lock = threading.Lock()
_ready_pairs = set()
def _clean(text):
"""翻訳前の正規化。プロンプトのエスケープ括弧を素の括弧へ戻す。"""
return (
(text or "")
.replace("\\(", "(").replace("\\)", ")")
.replace("\\[", "[").replace("\\]", "]")
.strip()
)
def _google_translate(tags, src, to):
"""deep-translator(Google, APIキー不要・要ネット)。高品質だが失敗し得る。"""
from deep_translator import GoogleTranslator
g = GoogleTranslator(source=src, target=to)
out = []
for t in tags:
c = _clean(t)
out.append(g.translate(c) if c else "")
return out
def _ensure_pair(src, to):
"""(src,to) の argos 言語パッケージを用意(未導入なら取得・インストール)。"""
key = (src, to)
if key in _ready_pairs:
return
with _install_lock:
if key in _ready_pairs:
return
installed = {(p.from_code, p.to_code) for p in _pkg.get_installed_packages()}
if key not in installed:
_pkg.update_package_index()
avail = _pkg.get_available_packages()
cand = [p for p in avail if p.from_code == src and p.to_code == to]
if not cand:
raise RuntimeError(f"argos package not found: {src}->{to}")
_pkg.install_from_path(cand[0].download())
_ready_pairs.add(key)
def _argos_translate(tags, src, to):
"""argostranslate(オフライン/API不要)。品質は控えめだがネット不要。"""
_ensure_pair(src, to)
out = []
for t in tags:
c = _clean(t)
out.append(_tr.translate(c, src, to) if c else "")
return out
def _translate_sync(tags, src, to):
# まず Google(高品質・要ネット)、失敗時に argos(オフライン)へフォールバック
try:
return _google_translate(tags, src, to)
except Exception as e:
print(f"[PromptTagEditor] google translate failed, fallback to argos: {e}")
if not _ARGOS_OK:
raise RuntimeError("google failed and argostranslate not available")
return _argos_translate(tags, src, to)
if PromptServer is not None:
@PromptServer.instance.routes.post("/prompt_tag_editor/translate")
async def _route_translate(request):
try:
data = await request.json()
except Exception:
return web.json_response({"error": "invalid json"}, status=400)
tags = data.get("tags") or []
src = data.get("from", "en")
to = data.get("to", "ja")
if not isinstance(tags, list):
return web.json_response({"error": "tags must be a list"}, status=400)
loop = asyncio.get_event_loop()
try:
# ブロッキング処理(初回DL/推論)はexecutorへ逃がしイベントループを止めない
translations = await loop.run_in_executor(
None, _translate_sync, tags, src, to
)
return web.json_response({"translations": translations})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)