翻訳機能を追加(チップ下に表示・言語自動検出)
- 各タグチップの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:
co-authored by
Claude Fable 5
parent
8f57e86770
commit
7b31cb53be
@@ -18,6 +18,7 @@
|
||||
- `+` / `−` で**重み調整**(`(word:1.2)` 構文、0.05刻み、1.0付近で括弧自動除去)
|
||||
- **ドラッグ&ドロップで並べ替え**
|
||||
- `×` で**削除**
|
||||
- `訳` で**日本語訳をチップ下に表示**(Google翻訳=要ネット・APIキー不要を優先、失敗時は argostranslate=オフラインへフォールバック)
|
||||
- **非破壊**: 値はネイティブのテキストウィジェットが保持するため、ワークフロー保存・実行時プロンプトに確実に反映されます。LoRA `<lora:...>`・`BREAK`・エスケープ・括弧内カンマも壊しません。
|
||||
|
||||
設定 → `Prompt Tag Editor: タグ編集エリアを表示` でチップエリアのON/OFFができます。
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# 翻訳: Google(要ネット・APIキー不要)を優先、失敗時は argostranslate(オフライン)へフォールバック
|
||||
deep-translator
|
||||
argostranslate
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user