[Astro] #170 Subtitle Converter — SRT / VTT / ASS / SBV / LRC の変換とズレ補正(SJIS対応)

[Astro] #170 Subtitle Converter — SRT / VTT / ASS / SBV / LRC の変換とズレ補正(SJIS対応)

はじめに

前回は、PSD / PSB ビューアの作り直しでした。

今回は、プロジェクトマップの「未開拓アイデア」にあった、Subtitle Converter です。データ / ファイル操作のツールの中で、テキスト系の形式だけが、手薄でした。

作った日は、利用枠が残り 6% ほどしかありませんでした。そこで、重いものは避けて、いちばん軽そうなものを選びました。自前のパーサーだけで、WASM もライブラリも要らないからです。ここまでの利用枠の消費は、約 1% でした。

軽いぶん、機能は多めにしました。今回の要点は、次の 6 つです。

  1. 5 つの形式を、「開始・終了・本文」の形にそろえて、相互に変換する
  2. 文字コードを自動で判定し、Shift_JIS でも書き出す
  3. ズレの種類ごとに、直し方を分ける(ずらし、FPS、2 点指定、動画に合わせる)
  4. 動画や音声を読み込んで、字幕を重ねて確認する
  5. 整形(タグ、効果音、重複、重なり、短すぎる字幕)をまとめて直す
  6. 使い方は、ヘルプのダイアログに入れて、サンプルも付ける

完成画面

サンプルを読み込んだところ

サンプルの字幕 10 行と、動画のプレビューが表示された Subtitle Converter

左がファイルと文字コード、中央が字幕の一覧とプレビュー、右が補正と書き出しのツールです。一覧は、時刻も本文も、その場で書き換えられます。赤い ⚠ は、終了が開始より前か、次の字幕と時間が重なっている行です。サンプルでは、3 件が出ます。

ヘルプのダイアログ

使い方を説明するヘルプのダイアログ

最初は、使い方を左のパネルに、そのまま書いていました。メインの画面には、うるさかったので、ヘッダーの「? HELP」から開くダイアログに移しました。

動画

サンプルの字幕 10 行と、動画のプレビューが表示された Subtitle Converter

5 つの形式を、同じ形にそろえる

形式ごとに、時刻の書き方が違います。

形式時刻の例特徴
SRT00:01:02,500番号、時刻の行、本文。空行で区切る
WebVTT00:01:02.500先頭に WEBVTT。時間が mm:ss.mmm だけの書き方もある
ASS0:01:02.501/100 秒。スタイルや位置を持つ
SBV0:01:02.500,0:01:05.000開始と終了を、カンマで 1 行に書く
LRC[01:02.50]開始だけ。終了を持たない

読み込んだら、全部、次の形にします。時刻は、ミリ秒の整数です。

{ s: 1500, e: 3000, text: '本文', style?: 'Default', ... }

時刻は、1 つの正規表現で読む

SRT と VTT と ASS で、時刻を読むところを、共通にしました。時間は省けて、小数部は、1〜3 桁なら読めます。

const TIME_RE = /(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:[.,:](\d{1,3}))?/;
// 小数部は 3 桁にそろえる("5" も "50" も "500" も 500 ミリ秒)
const fr = m[4] ? Number((m[4] + '00').slice(0, 3)) : 0;

一覧に打ち込む時刻にも、同じ関数を使っています。そのため、1:23 のような短い書き方も通ります。

形式の判定

拡張子より先に、中身で判定します。WEBVTT で始まれば VTT、[Script Info] や Dialogue: があれば ASS、--> があれば SRT か VTT(小数点がカンマかピリオドかで分ける)、というふうに見ていきます。拡張子は、最後の手がかりです。貼り付けたテキストにも、同じ判定が使えます。

ASS の読み方

ASS は、Format: の行で列の並びが決まっています。Dialogue: の行を、列の数だけカンマで区切ります。ただし、本文にはカンマが入るので、最後の列には、残りを全部入れます。

読み込むときに、[Events] より前の部分(スタイルの定義など)を、そのまま取っておきます。ASS から ASS に書き出すときは、これを使うので、元のスタイルが保たれます。

本文のタグは、変換して書き出す

本文のタグは、読み込んだ形のまま持っておいて、書き出すときに変換します。

  • ASS → SRT / VTT:{\i1} を <i> に直す(斜体・太字・下線だけ)。残りの {...} は消す
  • SRT / VTT → ASS:<i> を {\i1} に直す。ほかのタグは消す
  • SBV / LRC:タグは、すべて消す

ASS の位置指定(\an8 など)は、SRT や VTT に出すときに、消えます。

LRC は、終了を作る

LRC には、終了時刻がありません。次の行の開始を、前の行の終了にしています。最後の行だけは、開始の 3 秒後にしました。この 3 秒は、私が決めた値で、仕様ではありません。

文字コードを、判定する

日本語の字幕は、Shift_JIS のものが、今でもあります。UTF-8 と決めつけると、文字化けします。判定の順番は、次のとおりです。

  1. BOM があれば、それに従う(UTF-8、UTF-16 LE、UTF-16 BE)
  2. BOM がなくても、先頭の 4000 バイトに、0x00 が多ければ、UTF-16 とみなす(偶数番目か奇数番目かで、LE と BE を分ける)
  3. UTF-8 として、厳密に読めれば(fatal: true)、UTF-8
  4. それ以外は、Shift_JIS、EUC-JP、GBK、Big5、EUC-KR で、それぞれ読んでみて、点数をつけて、いちばん高いものにする

4 つ目の点数は、次のように付けています。

  • 読めない文字(U+FFFD)は、大きく減点する
  • ひらがなとカタカナは、日本語の文字コードのときだけ、加点する
  • 漢字は、少し加点する
  • 半角カタカナは、減点する(EUC-JP の文字を Shift_JIS で読むと、半角カタカナだらけになるため)

どれも、決め打ちの重みで、理論があるわけではありません。

短い文で外れた

テストで、1 つだけ、外れました。「こんにちは、世界。アイウ〜」のような、短くて、半角カタカナが入った文を、Shift_JIS で保存して読ませたところ、GBK と判定されました。半角カタカナの減点が、Shift_JIS には重すぎました。Shift_JIS のときだけ、減点を半分にして、直しました。

短い文は、判定の材料が少ないので、外れやすいです。そのため、左のパネルに、文字コードを手で選ぶ欄を置いてあります。選び直すと、同じバッファから読み直します。

Shift_JIS で書き出す

古いプレーヤーや機器では、UTF-8 の字幕が読めないことがあります。そこで、Shift_JIS でも書き出せるようにしました。

ブラウザの TextEncoder は、UTF-8 しか書けません。そこで、TextDecoder('shift_jis') の方を使って、逆引きの表を作りました。2 バイトの組み合わせを、全部、読ませて、1 文字になったものを、表に入れていきます。

const d = new TextDecoder('shift_jis');
// 上位バイト 0x81〜0x9F, 0xE0〜0xFC / 下位バイト 0x40〜0xFC(抜粋)
for (let l = 0x81; l <= 0xFC; l++) {
  if (l >= 0xA0 && l <= 0xDF) continue;
  for (let t = 0x40; t <= 0xFC; t++) {
    const s = d.decode(new Uint8Array([l, t]));
    if (s.length === 1 && s !== '�' && !SJ.has(s)) SJ.set(s, [l, t]);
  }
}

表は、最初に使うときだけ作ります。ブラウザの Shift_JIS は、実質、Windows の CP932 です。

文字の対応を、少し直す

Unicode と CP932 で、同じ文字が、別の番号になるものがあります。「〜」(U+301C)は、CP932 では「~」(U+FF5E)です。このような 5 つの文字だけ、書き出す前に置き換えます。

  • U+301C(〜)→ U+FF5E
  • U+2212(−)→ U+FF0D
  • U+2016(‖)→ U+2225
  • U+2014(—)→ U+2015
  • U+00A6(¦)→ U+FFE4

表にない文字は、? にして、何文字あったかを、画面の下に出します。

ズレを直す

字幕のズレには、種類があります。それぞれに、使うものを分けました。

症状使うもの
最初から最後まで、同じだけズレるSHIFT(ミリ秒。一部の範囲だけも可)
動画を見ながら合わせたい動画を読み込んで、行をクリックして、再生位置に合わせる
後ろにいくほどズレる(FPS 違い)FPS の変換
前半は合うが、後半のズレ幅が違う2 点指定

FPS の変換と、2 点指定は、同じ式

FPS の変換も、2 点指定も、中身は、時間の 1 次式です。

// 新しい時刻 = t1 + (今の時刻 - s1) × k
const linear = (cs, t1, s1, k) => cs.map(c => ({
  ...c,
  s: Math.max(0, t1 + (c.s - s1) * k),
  e: Math.max(0, t1 + (c.e - s1) * k)
}));
  • FPS の変換:t1 = 0, s1 = 0, k = 元の FPS ÷ 動画の FPS(23.976 → 25 なら、k は約 0.959)
  • 倍率の指定:同じく、t1 = 0, s1 = 0 で、k を直接指定する
  • 2 点指定:字幕 A と B の、今の開始が s1、s2。合わせたい時刻が t1、t2。k = (t2 - t1) ÷ (s2 - s1)

1 つの式にしたので、コードが少なく済みました。

動画に合わせる

動画を読み込んで、ズレている字幕の行をクリックします。動画を、その台詞が始まる瞬間で止めて、「Sync selected row to video time」を押します。いまの再生位置と、その字幕の開始との差を、全体のずらしとして適用します。

行ごとに、「開始 = 再生位置」(S)と「終了 = 再生位置」(E)のボタンも付けました。字幕を、1 行ずつ、動画に合わせて作り直すときに使います。

整形

次の項目を、チェックして、まとめて実行できます。

  • タグを消す
  • [..]、(..)、【..】、♪..♪ を消す(効果音、話者、歌)
  • 行の前後の空白と、連続するスペースを整える
  • 中身が空の字幕を消す
  • 開始時刻の順に並べる
  • 同じ文が続いたら、1 つにまとめる(開始が、前の終了から 0.1 秒以内のとき)
  • 終了を、次の字幕の開始までに切り詰める
  • 短すぎる字幕を、最小の長さまで延ばす(次の字幕の手前まで)

そのほかに、検索と置換(正規表現も可)、改行の解除、指定した文字数での折り返しがあります。

実行する順番は、決まっています(文字の整形 → 空の字幕を消す → 並べ替え → 重複 → 重なり → 最小の長さ)。重なりを直すより先に、並べ替えないと、直し方がおかしくなるためです。

動画のプレビューと、サンプル

動画や音声のファイルを、画面にドロップすると、字幕を重ねて表示します。ブラウザの <video> に、requestAnimationFrame で、現在の再生位置の字幕を出しているだけです。再生中の行は、一覧で、ハイライトされます。

ビープ音つきのサンプル

使い方を説明するだけでは、試せません。そこで、「Load sample」のボタンを付けました。押すと、字幕が 10 行と、30 秒の音声が読み込まれます。

音声は、JS で作っています。8kHz、8bit、モノラルの WAV で、各字幕の開始位置に、880Hz の短いビープを入れています。

// 開始位置から 0.12 秒、減衰する 880Hz のビープ
d[44 + a + i] = 128 + Math.round(60 * Math.sin(2 * Math.PI * 880 * i / sr) * (1 - i / len));

そのため、再生すると、ビープが鳴るのと同時に、字幕が出ます。ここで、SHIFT の「+1s」を押すと、字幕が 1 秒遅れてズレます。耳でも目でも、ズレが分かるので、ズレ補正の練習になります。

サンプルの字幕には、効果音の [BGM]、時間が重なる行、同じ文の連続、短すぎる行、並び順が逆の行、空の行を、わざと入れてあります。整形を、全部オンにして実行すると、10 行が 8 行になります。

一覧の編集と、やり直し

一覧の行は、時刻も本文も、その場で書き換えられます。行ごとに、シーク(▶)、追加(+)、削除(✕)のボタンがあります。

やり直し(Undo)は、操作の前に、字幕の配列を JSON にして取っておく方法です。ファイルごとに、40 段まで取っておきます。字幕の配列は小さいので、この単純な方法にしました(大きなファイルでの重さは、測っていません)。

複数のファイルを読み込んだときは、左の一覧で切り替えます。「apply to all files」にチェックを入れると、ずらしや整形が、全部のファイルに適用されます。

テスト

形式の読み書きと、文字コードは、Node で確認しました。<script> の中身を取り出して、vm で動かします。ブラウザの部品(DOM)が無いと、コアの部分だけが動く作りにしてあるためです。

確認した項目は、次のとおりです。

  • 5 つの形式の、相互の往復(SRT → 各形式 → 読み直して、時刻と本文が合う)
  • ASS のヘッダーを保って書き出す
  • 時刻の読み書き
  • UTF-8、UTF-8 BOM、UTF-16 LE、Shift_JIS の書き出しと、自動判定での読み直し
  • EUC-JP の判定
  • 整形、折り返し、線形補正
  • サンプルの字幕が、読めて、整形で 8 行になること

全部で 29 項目で、最初は 1 つ外れました(前に書いた、Shift_JIS の短い文)。画面(DOM、動画のプレビュー、ダイアログ)は、ブラウザで、目で確認しました。

ハマりどころ

1. HTML の {...} は、式として読まれる

整形の説明に、{\an8} と書いたら、画面が Syntax error "a" で落ちました。Astro は、HTML の中の波括弧を、JavaScript の式として読みます。&#123; と &#125; に書き直しました。<script> と <style> の中は、読まれません。

2. スコープ付きの CSS は、あとから作った要素に効かない

最初に画面を出したら、字幕の行が、スタイルなしで並びました。Astro の <style> は、data-astro-cid-... という属性で、効く範囲を絞っています。JavaScript で、あとから作った要素には、この属性が付かないので、CSS が当たりません。

あとから作る要素(字幕の行、ファイルの一覧、空のときの案内)の CSS は、<style is:global> に移しました。ほかのページに漏れないように、すべて #app の下に限定しました。

3. テストの取り出しが、コメントに引っかかった

テストで、<script is:inline> の中身を、正規表現で取り出していました。ところが、ファイルの先頭のコメントにも、同じ文字列を書いていたので、そちらに先に当たって、動きませんでした。取り出しを、最後の <script> に変えて、直しました。

1 と 2 は、私が書いたコードを、ブラウザで開いて、初めて分かりました。Node のテストでは、見つけられない種類のものです。

未対応のこと

  • 数千行の字幕での、表示の速さ:一覧は、全部の行を、画面に作っています。測っていません。遅ければ、見えている分だけ作る方式に変えます
  • ASS から、ほかの形式への変換で、位置とスタイルは消えます(斜体・太字・下線は残る)
  • ASS のスタイルを編集する画面:ありません。書き出すときの、フォントとサイズを選ぶだけです
  • WebVTT の位置の指定(line: や position: など)、NOTE と STYLE のブロック:読み込んだあと、残りません。番号(識別子)も、書き出すときに振り直します
  • 拡張 LRC の、単語ごとの時刻:未対応です
  • 日本語の折り返し:文字数で、均等に割るだけです。ASS のタグの途中で、切れることがあります
  • Shift_JIS 以外の古い文字コード(EUC-JP など)への書き出し:未対応です
  • 実際の字幕ファイルでの確認:サンプルと、自分で作ったテスト用の文字列だけです。実際のファイルを使うのは、これからです

おわりに

軽いはずの題材でしたが、形式が 5 つあるので、時刻の読み方と、タグの扱いと、文字コードで、思ったより手がかかりました。

一番、効いたのは、「内部の形を 1 つに決める」ことでした。開始と終了のミリ秒と、本文だけにしたので、5 つの形式の読み書きは、それぞれ独立して作れました。ズレの補正も、全部、同じ 1 次式で済みました。

次にやるなら、実際の字幕ファイルで使ってみて、判定の外れや、形式の癖を拾うことです。ただ、これは私の見込みで、どこで外れるかは、まだ分かりません。

実装メモは、docs/subtitle-converter.md に、残してあります。