PR
個人開発 ffmpegNode.jsデータ設計ハングル個人開発推し韓マル開発ノート書き順

画のデータひとつで、アプリ・Web・書き順動画・判定サーバーをそろえる 2,351 字の書き順データの作り方

筆と墨で文字を書いている紙 個人開発

ハングルの書き取り練習のサービスでは、「画のデータ」を 4 か所で使っています。

使う場所使い方
アプリ(推し韓マル)書き順の再生・1 画ずつの判定
Web の判定サーバー(Cloudflare Workers)1 画ずつの判定(正解の座標はサーバーの外に出さない)
Web の書き順動画文字ごとに mp4 であらかじめ描いておく
Web の文字ページ 2,351 枚画数・組み立ての表示

元になるのは、stroke-approved.json というファイルひとつです。どこか 1 か所だけが変わり、ほかがそのままだと、「再生はこの順番なのに、判定は別の順番」という事故が起きます。そこで元をひとつに決め、残りはすべてビルドで作るものにしました。1 画ずつ判定する仕組みそのものは、前回にまとめています。

保存の形は単純です。マスの 1 辺を 1000 とした整数の座標を、画ごとに書く向きの順に並べます。

{ "chars": { "하": [[x0, y0, x1, y1, ...], [...], [...]] } }

整数にしておくと JSON が小さくなり、比較やハッシュが安定し、マスの大きさが違っても / 1000 するだけで済みます。

スポンサーリンク

データを作る:自動生成+人の検収+確定版を優先

自動生成

  • 順番・向き・画数 = オープンソースの hangul-writer(MIT・51 字母・初声 → 母音 → パッチム)。音節は Unicode の分解で字母を求めます
  • 線の位置 = 画面に実際に見える見本フォント(Gowun Dodum)の輪郭を画ごとに分け、その画の取り分の真ん中を通る線

音節から字母への分解は、Unicode の計算 1 行で済みます。

export function jamoOf(ch) {
  const c = ch.codePointAt(0) - 0xac00;
  if (c < 0 || c > 11171) return null;
  const jong = JONG[c % 28];
  return [CHO[Math.floor(c / 588)], JUNG[Math.floor((c % 588) / 28)], ...(jong ? [jong] : [])];
}

検収済みの確定版を、計算より優先する

自動生成の規則は、どうしても何度も直すことになります。規則を直すたびに、すでに人が目で確かめた文字まで変わってしまうと、検収がいつまでも終わりません。そこで、目での検収が終わった文字は確定版に保存し、ビルドは確定版にある文字を計算せず、そのまま使うようにしました。確定版にない文字だけを計算し、点検します。

自動ではうまくいかない文字は、手動の規則ファイルで直します。輪郭をどの字母に割り当てるか、どの画にどの輪郭を使うか、案内の線を直接指定するかを決めます。

ビルドは厳しく失敗させる

アプリに出てくる文字がひとつでも抜けたり、点検に引っかかったりしたら、ビルドを失敗させる(抜けたまま・間違ったまま出さないため)。

点検する項目は次のとおりです。

  • 取り分のない画・持ち主のいないインク(フォントの輪郭のうち、どの画にも割り当てられていない部分)
  • 形の違う画・向きの間違い
  • 案内の線の長さ・見本の中に収まっているか

実際のビルドのログは、次のような形です。これはアプリに実際に出てくる文字を対象にしたログで、Web 全体の 2,351 字とは別の数え方です。

[strokes] src/content/stroke-paths.json · 1084 / 1084 字 (100 %) · 524 KB · 手動規則 14 字 · 検収確定版を使用 1084 字
[strokes] 点検通過 (取り分のない画 0 · 形の違う画 0 · 向きの間違い 0 · 持ち主のいないインク 0 · 案内の線の長さ · 見本の中 · 向き 通過)

書き順の動画 2,351 本をビルドで作る

Web には画の座標を出さないことにしました(データの保護のため)。その代わり、アプリの再生画面と同じ絵を、文字ごとに mp4 としてあらかじめ作っておきます。

  • 描画 = @resvg/resvg-js(SVG → RGBA)
  • 圧縮 = ffmpeg(H.264・どのブラウザでも再生・faststart)
  • 並列 = worker_threads

フレームをファイルに書かず、ffmpeg の標準入力に流し込む

const ff = spawn('ffmpeg', [
  '-y', '-loglevel', 'error',
  '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${SIZE}x${SIZE}`, '-r', String(FPS), '-i', '-',
  '-an', '-c:v', 'libx264', '-preset', 'slow', '-crf', String(CRF),
  '-pix_fmt', 'yuv420p', '-movflags', '+faststart', tmp,
], { stdio: ['pipe', 'ignore', 'pipe'] });

for (let f = 0; f < frames; f += 1) {
  const svg = frameSvg(base, arrows, play, (f * 1000) / FPS);
  // システムのフォントを読まない(絵に文字がない・1 枚ごとに読むと 10 倍遅くなる)
  const rgba = new Resvg(svg, { fitTo: { mode: 'original' }, font: { loadSystemFonts: false } }).render().pixels;
  if (!ff.stdin.write(rgba)) await new Promise((r) => ff.stdin.once('drain', r));   // 背圧の処理
}
ff.stdin.end();
await done;
renameSync(tmp, job.out);   // 一時ファイル → 完成後に名前を変える(途中で落ちても壊れた mp4 が残らない)

「変わった文字だけ」作り直すための指紋

2,351 本を毎回作り直すと、時間がかかりすぎます。結果に影響するすべての入力をハッシュにまとめて記録しておき、違うときだけ作り直します。

const fontSig = createHash('sha256').update(readFileSync(fontPath)).digest('hex').slice(0, 16);
const settings = JSON.stringify({ RENDER_VERSION, SIZE, FPS, CRF, STROKE_MS, GAP_MS, HOLD_MS, FADE_MS,
  GUIDE_RATIO, GUIDE_BASELINE, play, fontSig });

for (const [ch, strokes] of Object.entries(chars)) {
  const hex = ch.codePointAt(0).toString(16);
  const hash = createHash('sha256').update(settings).update(JSON.stringify(strokes)).digest('hex').slice(0, 16);
  if (manifest[hex] === hash && existsSync(out)) continue;   // 同じ入力 = 飛ばす
  jobs.push({ ch, hex, hash, out, strokes });
}

ポイントは次のとおりです。

  • フォントのファイルそのもののハッシュも入れる。フォントを替えただけでも、すべて作り直される
  • 描くコードを変えるときのために RENDER_VERSION を置く。数字をひとつ上げれば、すべて作り直す
  • 進み具合は 50 本ごとに保存する。途中で止まっても、次の実行が続きから作る

スポンサーリンク

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

楽天で見る

スポンサーリンク

モバイルアプリ開発の知識地図 / 國師誠也

モバイルアプリ開発の知識地図 / 國師誠也

Yahoo!ショッピングで見る

手書きを筆ペンの線のように整える(判定とは切り離す)

指を離すと、0.18 秒でガタガタの線が整った筆ペンの線に変わります。気持ちのよい結果を見せるためのもので、判定には書いたままの元の点を送ります。整えた結果で判定すると、間違った画も正しく見えてしまうからです。

手順は次のとおりです。

  1. 同じ間隔で打ち直す(指の速さによる点の密度の差をなくす)
  2. 震えをならす(隣どうしの平均を 4 回)
  3. 折れる角で分ける(約 57° 以上)
  4. ほぼまっすぐな区間は直線にする。水平・垂直から ±9° 以内なら、ぴったり水平・垂直にそろえる
  5. 曲線の区間はもう一度なめらかにする → つなぎ合わせる
  6. 太さ:書き始めを少し太く、終わりの 30 % で細く抜く
export const BEAUTIFY_STEP = 0.0094;       // 打ち直す間隔(マスに対して)
export const BEAUTIFY_SMOOTH = 4;          // 震えをならす回数
export const BEAUTIFY_CORNER_ANGLE = 1.0;  // 角の判定(ラジアン)
export const BEAUTIFY_STRAIGHT = 0.07;     // まっすぐな区間の判定
export const BEAUTIFY_SNAP_DEG = 9;        // 水平・垂直にそろえる範囲
export const BRUSH_HEAD = 1.15;            // 書き始めの太さの倍率
export const BRUSH_TAIL_FROM = 0.7;        // 細くなり始める地点
export const BRUSH_TAIL = 0.45;            // 終わりの太さの倍率

すべての長さをマスの大きさに対する比率にしておいたおかげで、アプリ(React Native)と Web(ブラウザの JS)に同じ値をそのまま移し、同じ書き味を出せています。

元のデータを変えるときの手順(文書で固定)

  1. 確定版を直す → コンテンツのビルド(アプリのデータを作り直し・点検)→ 判定のテスト
  2. 動画を作る(変わった文字だけ)
  3. 判定サーバーのローカルでのテスト → 配信(Web の配信スクリプトが判定サーバーの応答を確かめるので、サーバーが先)
  4. Web の配信 → 本番のサイトで変わった文字を確かめる
  5. コミット(元のデータ+作ったもの)
  6. アプリは次のビルドに入れる

判定サーバーの作り方は、後の回で書きます。

つまずいたこと

  1. 見本の文字を画の領域で切り出して見せる方式は捨てた。最初は、フォントの文字を画ごとに切り出し、1 画ずつ見せようとしました。「같・놓・있」のように画がくっついた文字で、境目の推測が外れました。今は「出発点+細い線+終わりの矢じり」の案内の線の方式です
  2. 二重母音・二重パッチムは、部分に分けなければならない。ㅘ のような大きな箱が初声の輪郭まで持っていってしまい、과・왜・의・쉽・뭘・뷁 で組み合わせに失敗しました。ㅘ = ㅗ + ㅏ、ㄳ = ㄱ + ㅅ のように分けると解決しました
  3. 動画の位置のずれ。書くマス(正方形の SVG)より、マスの外枠のほうが広くなる画面(高さの足りない PC のウィンドウ)で、動画は外枠の真ん中に描かれ、見本は左に寄っていました。再生するときだけ文字が横にずれて見えたのです。マスを width: fit-content; max-width: 100%; margin-inline: auto; で SVG と同じ正方形にそろえて解決しました
  4. H.264 を再生できないブラウザもある。オープンソースの Chromium のビルドなどです。再生の前に video.canPlayType() で H.264 の mp4 を再生できるかを確かめ、できなければ再生ボタンを隠します

スポンサーリンク

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

楽天で見る

スポンサーリンク

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

Yahoo!ショッピングで見る

この記事について

自社のハングル書き取り練習アプリ「推し韓マル」と、Web「iffthen hangul」の書き順データを作ったときの記録です。コードとログは実際のソースとビルドから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。

コメント

タイトルとURLをコピーしました