WordWarp ができるまで — OPUS-MT をブラウザで動かして翻訳をローカル化する
WordWarp は、テキストを サーバーに送らずに 翻訳する Webサービスです。 @xenova/transformers (transformers.js) + OPUS-MT (Helsinki-NLP) の量子化済み ONNX モデルを WASM 上で動かし、 入力テキストは一切ブラウザ外に出ません。 メディア処理 9本柱に続く、 ラボ 10本目。 voice-scribe / text-pluck の出力を翻訳に流す Process Chain の起点でもあります。
なぜこの形にしたか
翻訳に入力する文章は、 メール下書き、 健康相談、 契約書ドラフト、 仕事の指示書、 機密情報を含むものが多い。 これらが Google翻訳 / DeepL / OpenAI API を経由する設計は、 一度立ち止まって考える価値があります。
OPUS-MT (Helsinki-NLP) は OSS の中で実用範囲に到達した機械翻訳モデル群で、 言語ペアごとに 80MB 程度の量子化版がブラウザで動かせる規模になりました。 transformers.js が WASM + ONNX で OPUS-MT を成立させた今、 「外に出したくない文書専用の翻訳器」 を一つの製品として出すタイミングです。
visual direction
ヴェラム + バーミリオン (vellum & vermilion)。
- 深い羊皮紙ブラウン (
#110a06) を背景に、 赤い朱インク (#dc2626) と古銅 (#fbbf24) のグラデーション - 「ロゼッタストーン」 「写本」 「朱書き校正」 のメタファー
- 既存9本 (mint / amber / violet / cobalt / lime / gold / rose / indigo / emerald) と完全に別の色相 + 温度感 (warm sepia + crimson)
実装の見どころ
1. デュアルエンジン構成 (OPUS-MT + NLLB-200)
WordWarp は 2種類の翻訳エンジンを併用します:
- OPUS-MT (Helsinki-NLP) — 言語ペア毎の専用学習モデル。 各 ~80MB。 軽い + 主要ペアでは品質も実用範囲。
- NLLB-200 (Meta) — 200言語を 1モデルでカバーする多言語モデル。 ~430MB。 OPUS-MT が弱い言語 (特に日本語生成) で品質を担保するために使用。
export const PAIRS = {
"ja-en": { engine: "opus", modelId: "Xenova/opus-mt-ja-en", size: "~80MB" },
"en-ja": { engine: "nllb", modelId: "Xenova/nllb-200-distilled-600M",
nllbSrc: "eng_Latn", nllbTgt: "jpn_Jpan", size: "~430MB" },
"zh-en": { engine: "opus", modelId: "Xenova/opus-mt-zh-en", size: "~80MB" },
"en-zh": { engine: "opus", modelId: "Xenova/opus-mt-en-zh", size: "~80MB" },
"ko-en": { engine: "opus", modelId: "Xenova/opus-mt-ko-en", size: "~80MB" },
"en-fr": { engine: "opus", modelId: "Xenova/opus-mt-en-fr", size: "~80MB" },
"en-de": { engine: "opus", modelId: "Xenova/opus-mt-en-de", size: "~80MB" },
"en-es": { engine: "opus", modelId: "Xenova/opus-mt-en-es", size: "~80MB" },
}
なぜ英→日本語だけ別エンジン? OPUS-MT の英→日モデル opus-mt-en-jap は学習データの大半が聖書コーパスで、 「hello」 →「陰府は陰府に及ぶ」 のような意味不明な聖書的出力を返す致命的な品質問題があります。 一度 v1 から外しましたが、 「英→日が無い翻訳サービス」 は日本語話者向けには欠陥なので、 NLLB-200 (品質担保済の 200言語モデル) を別エンジンとして導入して復活させました。
2. モデル単位キャッシュ
NLLB-200 は同一モデルで多方向の翻訳をカバーするので、 ペア単位でキャッシュすると将来 「英→中」 「英→韓」 等を NLLB で増やした時にモデルが何度も再 DL される。 そこでキャッシュキーは modelId にしました:
const pipelineCache = new Map<string, unknown>()
pipelineCache.get(meta.modelId) // NLLB 1個 + OPUS 各 1個
3. NLLB は呼び出し時に src_lang / tgt_lang 指定
OPUS-MT は方向がモデルに焼き付いているので pipe(text) だけで翻訳できますが、 NLLB-200 は呼び出し時に FLORES コード (eng_Latn, jpn_Jpan, zho_Hans 等) を渡す必要があります:
// OPUS-MT
const out = await opusPipe(text)
// NLLB-200
const out = await nllbPipe(text, { src_lang: "eng_Latn", tgt_lang: "jpn_Jpan" })
wrap() 関数で engine 種別を見て呼び分けています。 OPUS-MT には generate_kwargs を渡すと v2.17 で 「Cannot convert undefined or null to object」 を投げるクセがあるので、 OPUS は素の text のみ、 NLLB は src/tgt 指定のみ、 と engine 毎に最小限の引数を渡す設計に統一しました。
2. 1行ロード
pipeline("translation", modelId) でモデルとタスクパイプラインが返ってくるので、 そのまま await pipe(text) で翻訳できる。 高水準 API のおかげで runner はほぼ薄いラッパーです。
const pipe = await pipeline("translation", meta.modelId, {
quantized: true,
progress_callback: (e) => onProgress({ kind: "download", ratio: e.progress / 100 }),
})
const out = await pipe(text, { max_new_tokens: 512 })
3. 段落分割
OPUS-MT は 1回の推論で 512 トークン程度が上限です。 長文を一気に投げるとサイレントに truncate されてしまう。 WordWarp は入力を空行で段落分割し、 各段落を順に翻訳して結合します。
const paragraphs = text.split(/\n\s*\n/)
const results = []
for (const para of paragraphs) {
results.push(await pipe(para.trim()))
}
return results.join("\n\n")
これで 1万文字程度までは実用範囲。 構造を保ったままの翻訳が出ます。
4. swap ボタン
「日本語 → 英語」 で出た結果を 「英語 → 日本語」 に投げ直すケースは多いので、 ボタン一発でペアと入出力をひっくり返せるようにしました。 ja-en / en-ja、 zh-en / en-zh のように相互ペアがある場合のみ active。
5. 段落数の inference progress
「翻訳中…」 だけだと長文で固まったように見える。 段落数の中で何番目を訳しているかを paragraph 3/12 で見せると、 心理的待ち時間が大幅に短くなります。
苦労したところ
- Turbopack 経由の
await import("@xenova/transformers")がエラー: 開発・本番どちらでも Next.js 16 Turbopack の chunk loader が transformers.js モジュールを再エクスポートする際Object.keys(undefined)で死ぬ事象に遭遇 (Cannot convert undefined or null to object)。 対処は CDN ESM を直接await import(url)する こと:https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.jsを side-load してバンドラを経由しない経路にした。 NLLB 追加後もこの経路は維持。 - en→ja の OPUS-MT 致命傷 → NLLB-200 で回収: 公開直後に聖書コーパス問題で en→ja を一度ペアから外したが、 日本語話者向けに 「英→日が無い翻訳サービス」 は欠陥なので NLLB-200 (~430MB) を別エンジンとして導入して復活させた。 OPUS 7ペア + NLLB 1ペア の 8 ペア構成。
- ja↔zh の直接ペアがない: Xenova/opus-mt-ja-zh も opus-mt-zh-ja も 401 で取れず。 「日本語 → 中国語」 は将来 NLLB-200 で
jpn_Jpan → zho_Hansを足せばカバーできる (モデルは既に DL 済の ja↔ja-en route で兼用可能)。 - 段落分割と空行: 空行で split すると、 入力末尾の改行が空文字段落として残る。 翻訳をスキップしつつ改行構造を保つために空段落をそのまま
""で push し、 join で復元しています。
今後の拡張
- NLLB 経由で対応ペアを拡張: 既に NLLB-200 はロード可能なので、
jpn_Jpan → zho_Hans(日→中) 等の OPUS で直接ペアが無い方向を NLLB 1モデルでまとめて足せる - voice-scribe / text-pluck 連携: 出力テキストを WordWarp に渡す導線
- 用語集 / 翻訳メモリ: ユーザー定義の置換ルールを翻訳前後に適用
- 文書アップロード: pdf / docx / srt から本文を抜き出して一括翻訳
- エンジン選択UI: 同じ方向で OPUS / NLLB を比較できる切替モード (品質 vs 速度 / 容量)
このサービスから言える事
ラボの 10本柱:
- voice-scribe / clip-cast / bg-snap / text-pluck / pdf-anvil / pixel-lift / pic-flip / mind-cell / beam-drop / word-warp
メディア処理 7本 + 生成系 (mind-cell) + 転送系 (beam-drop) に続いて、 今度は変換系 (テキスト→別言語テキスト) もブラウザだけで揃った。 「外部 SaaS に文書を送らないと翻訳できない」 という常識が、 また一段更新時期に来ています。
[ ./next_action ]
読んだら、 WordWarp を実際に動かす。
この開発ログは WordWarp をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。