AI Dev Lab
WordWarp

WordWarp ができるまで — OPUS-MT をブラウザで動かして翻訳をローカル化する

@xenova/transformers と OPUS-MT (Helsinki-NLP) の量子化版で、 テキストをサーバーに送らずブラウザ内推論で翻訳する Webサービスの設計記録。 言語ペア切替、 段落分割、 IndexedDB キャッシュ、 8ペア対応の UX まで解説します。

·decision改善·stage公開中

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 をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — 文章embeddingsで意味検索をブラウザ内に

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →