From 7b31cb53be154422cd9d02d9dc6a408f0d4e97b6 Mon Sep 17 00:00:00 2001 From: 4thCafe Date: Fri, 17 Jul 2026 03:14:40 +0900 Subject: [PATCH] =?UTF-8?q?=E7=BF=BB=E8=A8=B3=E6=A9=9F=E8=83=BD=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0(=E3=83=81=E3=83=83=E3=83=97=E4=B8=8B?= =?UTF-8?q?=E3=81=AB=E8=A1=A8=E7=A4=BA=E3=83=BB=E8=A8=80=E8=AA=9E=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E6=A4=9C=E5=87=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 各タグチップの2段目に「訳」ボタン+翻訳表示(チップを常時2行化) - 言語自動検出: 日本語タグ→英訳を表示、英語タグ→何もしない - バックエンドは Google(deep-translator, APIキー不要)優先 + argostranslate (オフライン)フォールバック。エスケープ括弧を除去して翻訳精度向上 - server.py: /prompt_tag_editor/translate ルート、requirements追記 Co-Authored-By: Claude Fable 5 --- README.md | 1 + __init__.py | 7 +++ js/style.css | 34 +++++++++++++- js/tag_editor.js | 110 +++++++++++++++++++++++++++++++-------------- js/translate.js | 24 ++++++++++ requirements.txt | 3 ++ server.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 258 insertions(+), 36 deletions(-) create mode 100644 js/translate.js create mode 100644 requirements.txt create mode 100644 server.py diff --git a/README.md b/README.md index cfbd843..79f6804 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - `+` / `−` で**重み調整**(`(word:1.2)` 構文、0.05刻み、1.0付近で括弧自動除去) - **ドラッグ&ドロップで並べ替え** - `×` で**削除** + - `訳` で**日本語訳をチップ下に表示**(Google翻訳=要ネット・APIキー不要を優先、失敗時は argostranslate=オフラインへフォールバック) - **非破壊**: 値はネイティブのテキストウィジェットが保持するため、ワークフロー保存・実行時プロンプトに確実に反映されます。LoRA ``・`BREAK`・エスケープ・括弧内カンマも壊しません。 設定 → `Prompt Tag Editor: タグ編集エリアを表示` でチップエリアのON/OFFができます。 diff --git a/__init__.py b/__init__.py index 4288a70..0d07384 100644 --- a/__init__.py +++ b/__init__.py @@ -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 で出力する。 diff --git a/js/style.css b/js/style.css index ec54e04..84b690f 100644 --- a/js/style.css +++ b/js/style.css @@ -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; } diff --git a/js/tag_editor.js b/js/tag_editor.js index 502fead..82a8c80 100644 --- a/js/tag_editor.js +++ b/js/tag_editor.js @@ -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)); } diff --git a/js/translate.js b/js/translate.js new file mode 100644 index 0000000..8eac60c --- /dev/null +++ b/js/translate.js @@ -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; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f7ea451 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# 翻訳: Google(要ネット・APIキー不要)を優先、失敗時は argostranslate(オフライン)へフォールバック +deep-translator +argostranslate diff --git a/server.py b/server.py new file mode 100644 index 0000000..a3403eb --- /dev/null +++ b/server.py @@ -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)