手書きホワイトボードアニメーション制作の完全ワークフロー(SRT → 手書きアニメーション完成品)
オープンソースプロジェクト srt-whiteboard-animation(github.com/geeklee/srt-whiteboard-animation)の実践に基づいて構築されています。 出力物:ナレーション付き「ペンを持って描きながら説明する」ホワイトボードアニメーションMP4 + キースクリーンショット + 操作画面収録。
鉄律(違反した場合は作り直し。すべて実践での失敗に基づく)
- サンプル画像を成果物として使用しないこと。プロジェクトの
examples/にあるscene-01-monkey-mountain-banana.pngはパイプラインの検証用にのみ使用し、ユーザーが要求したテーマに基づいた画像を生成する必要があります。サンプル画像を成果物として納品することは、納品失敗を意味します。 - ソース画像は必ずクレンジングすること。AIが生成する生成的な黄土色の紙の背景には、多くのテクスチャ粒子が含まれています。レンダラーの
adaptiveThresholdは紙のテクスチャをインクの跡と誤認識し、完成した動画の線が点線のように崩れたり、背景にノイズが溢れたりします。レンダリングの前に必ずscripts/clean_source.pyを実行して紙のテクスチャを除去してください。 - 最後の要素に画像全体をリージョンとして使用しないこと。
allowed mask = region − 後続の regionであるため、最後の要素の region がキャンバス全体の場合、それ以前のすべての要素の allowed マスクが空になり、最初のいくつかのシーンでは何も描画されず、色がすべて失われます(実践で確認済み)。最後のシーンでは、実際の小さな領域(例:上部の雲の帯)を選択してください。 - 手の素材はキャンバスサイズに合わせてスケーリングすること。オリジナルの
drawing-hand.pngは 1069×1472 であり、1280×720 に直接貼り付けると画面の 40% 以上を占有し、コンテンツを著しく隠してしまいます。実践的には 55%(587×809)に縮小することで、手の幅がキャンバスの約 21% となり、見た目が正常になります。 - レンダリング出力の長さはアノテーションよりも短くなることがある。レンダラーは各領域の実際に描画可能なピクセルに基づいてフレーム数を決定するため、
sceneDurationMsはあくまで上限です。実践では、8×8000ms=64s のアノテーションが 45.8s の動画しか生成しないことがありました。--total-msを渡し、ffprobe で実際の長さを測定してキャリブレーションすることが必須です。 - 画面収録は kill で終了しないこと。ffmpeg を強制終了すると moov atom が失われ、ファイルで
moov atom not foundエラーが発生します。-t <秒>を使用して、収録を自然に終了させてください。 - 画面収録とスクリーンショットは必ず納品すること。これは要件の一部であり、オプションではありません。スクリーンショットは、ソース画像、アノテーション検証画像、レンダリングコンソール、完成品のキーフレームをカバーする必要があります。「ドキュメントによる記録でスクリーンショットを代用する」ことは未完了と見なします。
- 1つのキャンバスでは1つのことだけを説明し、かつそれは「連続的な進行」でなければならない。実践でのやり直し事例:赤旗、橋、雪山、塔を1枚の画像に並べて一度に同時に描画 → ユーザーから「描画が乱雑」と評価されました。正しい方法は、要素を1つのメインライン(地理的なルート/タイムライン)に沿って配置し、ペンの軌跡がメインラインの進行方向に沿って連続的に移動するようにすることです。視聴者は「ペンが歩いている」ように見え、「キャンバス上であちこちに細部を追加している」ようには見えません。
- 空間的なレイアウトは実際の進行方向に従わなければならない。長征を描く場合は、実際の地理に従う必要があります:瑞金は南東 → 西へ → 北へ → 陝北。構図が間違っていれば、リズムが正確でも修正できません。描画を開始する前に、地図上で各ノードの相対的な位置を確認してください。
- 手書きのリズム = ナレーションのリズム。1つの要素は1つの文に対応する。各要素の
durationMsは、対応するナレーション文の実際の長さと等しくなければなりません(sync_timing.pyを使用して無音検出から逆算します)。均等に割り当てることは絶対に避けてください。N番目のシーンのペンは、「N番目の文が話し終わった」瞬間に停止します。 - シーンとナレーションの意味は1対1で対応しなければならない。実践でのミス:宝塔(呉起鎮)を要約文のシーンに配置した結果、ナレーションが「呉起鎮に到着」と言っているときに、画面では別のものを描いていました。最後の要約文(「二万五千里…」)には新しい要素を割り当てず、「すべて描き終わった静止画」の上に配置してください。レンダラーは自動的に
--total-msまでの空フレームを追加して、見つめるような効果を出します。 - 字幕は別途焼き付ける必要があり、
force_styleに頼ってはいけない。純粋なSRTには PlayRes がなく、libass は内部のデフォルト解像度を使用してFontSize/MarginVを計算するため、実践では正確な値を調整できませんでした。make_srt.py→srt2ass.py(PlayRes を動画サイズに明示的に設定し、{\an2\pos(x,y)}を使用して下端にアンカーする)を使用することで制御が可能になります。 - 字幕は1行のみ許可される。2行にすると画面の上下2つの領域を同時に占有し、中国語の2行は読んでいても息継ぎができなくなります。1行に収まらない場合は、複数の連続した字幕に分割し(文字数に比例して長さを配分)、行を詰めたりフォントサイズを縮小したりしないでください。実践:46px のフォントサイズ + 1行 ≤14 文字は、720p で画面幅の約 60% を占め、スマートフォンで見るとちょうど良い感じになります。
- キャンバスは移動可能であるべきで、画像全体を固定フレームに詰め込まないこと。視聴者から「1枚のキャンバスで描き切る必要はない」というフィードバックがありました。正しい方法は、キャンバスを拡大して(2倍)レンダリングし、各シーンでは現在の要素だけをフルスクリーンでフレーミングし、描き終わったらカメラが次の要素にパンし、最後に全体像を見せるためにズームアウトすることです。最初から最後まで固定された全景では、各要素は画面の 10% しか占めず、何もはっきり見えません。
- ペン先はコンテンツに追従しなければならない。上流の
contour-wipeは「領域全体を下から上に消去 + ペン先が横にランダムにスキャン」するもので、両者は完全に切り離されています。その結果、ユーザーは「手は動いているが、何も描かれていない」と感じます。スネークブラシに変更します:ペンがスキャンした場所に色が現れます。 - 重要なポイントを説明するときは文字を書くこと。物だけを描いて文字を書かないと、視聴者は「何年、どこで」を覚えられません。地名/年号を楷書でベース画像に焼き付け(
add_handwriting.py)、レンダラーにtextwriteモードで列ごとに書き出させるようにします。ペンは筆順に従って進み、文字が1つずつ現れるため、「手書き」と同じ動作になります。 - キャンバスを拡大した後は2つのことを行わなければならない。さもないと遅くなるかぼやけるかのどちらかになる:
① ソース画像をまず
INTER_CUBICでキャンバスサイズに事前に拡大しておく(レンダラー内部のresizeはINTER_AREAであり、拡大時に最近傍補間と同等になり、線がブロック状になります)。 ② インクの描画はバウンディングボックスに基づいてローカルに処理するように変更する(上流ではセグメントごとにキャンバス全体の新しいマスクを作成しており、2560 キャンバスでは4倍遅くなります)。 - オーディオトラックを合成する前の完成動画には、最後のズームアウトのために 3~7 秒の余裕を持たせること。最後のシーンをナレーションの最後のミリ秒まで配置しないでください。そうしないと、「全体像を表示する」という締めくくりの動作が行われる時間がなくなります。
- フレーミングには「実際のインクのバウンディングボックス」を使用し、アノテーションの region は使用しないこと。region は手動で描画された枠であり、一般的に図形よりも一回り大きく、密集した画像では2~3個の隣接要素にまたがることもあります。これをそのまま zoom の計算に使用すると、結果は「画面の中央が空白で、図形は隅に縮こまり、隣の要素は半分に切られる」というものになります。レンダラーには組み込み済み:
set_target(element, allowed)内でink_pixels & allowedを使用して bbox を求め、さらに外側に拡張します。例外:テキスト字幕は bbox を使用しない。ラベルのテキストは region よりも幅が広いことが多く、bbox を適用すると文全体がフレームの外に切り取られます(実践では「対話式AI」が画面からはみ出しました)。 - シーン切り替えは、最初にカメラをパンしてから描画を開始すること。各シーンの開始時に約5フレーム(0.22秒)の空回しを行います。そうしないと、カメラが所定の位置に移動する前にペンが描画を開始し、画面外に描画してしまい、視聴者は「手は動いているがコンテンツがない」と感じます。
--zoom-fillはベース画像の密度に応じて個別に値を設定すること。疎な画像では 0.86、密なタイムラインでは 0.70。1つのパラメータですべてをまかなおうとすると、両方で問題が発生します。- 縦長の要素が画面を埋めきれないことを受け入れること。16:9 の画面では、縦方向の要素は高さの 60~70%、幅の 25% しか占有できません。これは幾何学的な制約です。より画面を埋めたい場合は、要素をより幅広く描くか、縦型の動画を出力してください。
- ナレーションは全体として一度に生成し、文ごとに分割してから後で結合するのは禁止。分割して結合すると、セグメントごとに話す速度/間/アクセントが一貫しなくなり、タイムラインが必然的にずれます。ユーザーからは「でたらめな話し方」と評価されます。Jimeng は一度に 1500 文字以上(約2分)を処理できるため、全体を一度に入力してください。
- タイムラインはナレーションの書き起こしタイムスタンプに基づき、無音検出には基づかない。間 ≠ 文の境界です。文の数と間の数が一致しない場合は、文字数に基づいて均等に割り当てるしかなく、字幕は必ずずれます。
asr_align.py(ffmpeg → faster-whisper word_timestamps → 文字レベルのタイムライン)を使用してください。 - ナレーションを変更した場合は、字幕とアノテーションの長さをすべて再生成しなければならない。字幕のタイムスタンプはそのオーディオファイルの属性であり、画像の属性ではありません。古い字幕を新しいオーディオに使用することは、何もしていないのと同じです。
- 納品前に difflib を使用して「スクリプト vs 書き起こし」をチェックすること。類似度が 0.8 未満の場合、または文全体の欠落/重複がある場合は、オーディオに問題があるため、再生成してください。同音異義語(弘/紅、尊/遵、武/呉など)は ASR の正常な現象であり、読み上げエラーとは見なされません。
- ナレーション生成後は、必ず無音の健康診断を行うこと。「全体を一度に TTS に送信する」ことは、プラットフォームが内部でチャンクに分割しないことを意味するわけではありません。Jimeng はチャンク間に -120dB の絶対無音を挿入します(実践で 4.00秒 / 2.60秒 を確認)。無音の割合が 20% を超える場合、または 1.2秒 を超える間がある場合は、まず
tighten_audio.pyを使用して圧縮してから、タイムラインのアライメントについて検討してください。 - 字幕の開始点は、実際に音声が発生するフレームにスナップすること。ASR は長い無音がある場合、次の文の start を無音の開始点にマークすることがあり、視聴者は「字幕を見ながら音声を待つ」ことになります。
asr_align.pyはデフォルトでsnap_to_voiceを使用して修正するため、無効にしないでください。
ディレクトリ構成(必須)
D:/04-プロジェクト実測/<YYMMDD>-<プロジェクト略称>/
00-資料/ プロジェクト資料とソースコードノート
01-素材/ 元のAI生成画像、手の素材
02-字幕とナレーション/ SRT、ナレーションmp3、長さテーブル
03-アノテーション/ <図名>.png + <図名>.annotation.json(同名で同じディレクトリにある必要あり。プレビューツールが依存)
04-レンダリング出力/ 個別シーン silent.mp4、完成品 mp4
05-画面収録スクリーンショット/ 画面収録 mp4 + キースクリーンショット png
06-ドキュメント/ スクリプト、操作記録、受け入れチェックリスト
前提条件
git clone https://github.com/geeklee/srt-whiteboard-animation.git
cd srt-whiteboard-animation && python scripts/prepare_env.py # .venv を作成し opencv/numpy/av をインストール
以降のすべてのレンダリングコマンドは srt-whiteboard-animation/.venv/Scripts/python.exe を使用します。
_lay_ink() takes 6 positional arguments but 7 were given エラーが発生した場合、これはリポジトリの既知のバグです:
render_stream_whiteboard.py 内の self._lay_ink(writer, ink_frames, [], set(), None, allowed)
の余分な None を削除してください(scripts/check_bug_fix.py で自動検出および修正可能)。
ワークフロー(7ステップ:ストーリーボード → 画像生成 → 空間配置 → アノテーション → ナレーションアライメント → レンダリング → 字幕)
フェーズ 1 · スクリプトとストーリーボード(先に確認してから次へ進む)
- 6~10文のスクリプトを作成します。各文は6~10秒、合計で40~90秒にします。
- ストーリーボードを解析します(オプション。手動で配置することも可能):
<ENV_PY> srt-whiteboard-animation/scripts/parse_srt.py 字幕.srt --target-sec 30 --min-sec 25 --max-sec 35 - ストーリーボードテーブル(シーン番号 / 核心となる表現 / 画面の主な被写体 / 字幕の原文)を出力し、ユーザーの確認を待ちます。
フェーズ 2 · AIによるテーマの手書き画像生成
プロジェクトの「統一された画像生成ビジュアルガイドライン」に従って画像生成のプロンプトを作成します。各プロンプトには以下が含まれている必要があります:
- ミニマルな手書き線画 / 純粋なスケッチ / Notion スタイルの控えめな落書き、16:9、余白を多く、要素は互いに重ならない
- 背景は暖かい黄土色
#F5EBD7、線は濃いグレー#333333、赤/オレンジ/青のみを控えめにアクセントとして使用 - いかなるテキストも絶対に禁止(漢字/アルファベット/数字/ラベル/地名/タイトル/透かし)、写実/3D/グラデーション/高彩度も禁止
ImageGen または Jimeng image を使用して画像を生成します。各画像はシーンの核となる意味に対応し、要素は長方形の領域で分割できる必要があります。
画像生成後は、必ず以下の3つを行います:
- 透かし除去:AI生成画像の右下には
AI生成 WORKBUDDYがあることが多いため、cv2.inpaintで消します。 - 16:9 への変換:生成画像は多くの場合 3:2 なので、左右に黄土色の紙の背景を追加してから 1280×720 にスケーリングします(中央で切り抜かないこと。被写体が切れる可能性があります)。
- クレンジング:紙のテクスチャを除去し、線とカラーのアクセントを保持します。
原理:<ENV_PY> scripts/clean_source.py 元画像.png 03-アノテーション/メイン画像.pngpaper = 大きなカーネルのメディアンフィルター、線 = gray < paper-18、色 = 彩度>70 かつ gray<190、線|色のみを保持し、残りは紙の背景で埋めます。周囲 8px は強制的に紙の背景とし、エッジの残留物を除去します。
フェーズ 2.5 · 空間的なナラティブボード(完成品の「乱雑さ」を決定します。必ず先にこれを描画すること)
画像生成プロンプトを作成する前に、まずテキストでメインラインを確定させます。2つのタイプに分類されます:
| テーマ | メインライン | レイアウトの原則 |
|---|---|---|
| 歴史的なルート / 戦役 / 移動 | 実際の地理的なルート | 開始点は実際の位置に。"西→北→東"と進む場合は、その通りに配置します。ノードの x/y は実際の方位と一致させる |
| 技術の歴史 / 会社の歴史 | 1本の水平なタイムライン | トラックが全体にわたって貫通し、ノードは等間隔、アイコンは上下交互に配置(上→下→上)。すべて片側に寄せるのは避ける |
ハードな制約:
- メインラインは1本のみ許可。メインライン上に「補足説明」のような散発的な要素を挿入しないでください。
- 要素間に重なりはなく、各要素はメインラインをまたがない長方形で囲める必要があります(そうしないと region が互いに打ち消し合います)。
- メインラインは全体にわたって貫通する必要があります。余白はメインラインの両側に均等に分布し、「すべて右下に寄っていて、左上は広大な空きスペース」のような状況は避けてください。 実践では、長征の南東の出発点が右下にあり、最初の20秒間は画面の右下が密集し、左上は完全に空いていました。これは構図自体の問題であり、画像生成時に開始点を中央に向かって移動させ、ルートがキャンバスを満たすようにする必要があります。
画像生成プロンプトでは、画面内の各要素の位置を指定する必要があります(「右下の小さな赤旗」「中央やや左の鉄索橋」など)。単に「橋を描いて」とだけ書かないでください。位置が指定されていないと、AIが提供する構図は使用できません。
フェーズ 2.6 · 2つのベース画像パラダイム(実践で確認されたパラメータ。そのままコピーして使用)
完成品が「画面を埋められるか、カメラを動かせるか」は、画像生成の段階で決まります。2つのパラダイムにはそれぞれ独自のパラメータセットがあり、混在させないでください:
A. 地理ルートタイプ(歴史/戦役/移動) —— 代表作:紅軍長征ルート
| 項目 | 値 | 根拠 |
|---|---|---|
| 構図 | 開始点は実際の方位に従い、ルートは西→北→東と全体にわたって貫通 | 構図が間違っていると、リズムが正確でも修正できない |
| ノード数 | 6~8駅 | 1駅2シーン(物を描く + 文字を書く)→ 12~16要素 |
カメラ --zoom-fill | 0.86 | 要素間の余白が多いため、ズームインしても空っぽに見えない |
| 密度 | 要素は大きくても小さくても可(橋は旗より3倍幅広でもOK) | ルート自体が異なるスケールのもので構成される |
B. 水平タイムラインタイプ(技術史/会社史/製品イテレーション) —— 代表作:Agent エージェント発展史 (ユーザーから「効果がとても良い」と評価されたバージョン。パラメータはそのままコピーして使用)
| 項目 | 値 | 根拠 |
|---|---|---|
| トラック | 1本のS字/波形の主軸が全体にわたって貫通し、8つのノードが等間隔 | 等間隔 → カメラのパン速度が一定で、見ていて安定する |
| アイコン | 上下交互(上→下→上…)、すべて1列に並べない | 1列に並べると、画面の半分しか使用できなくなり、フレーミングの縦方向のスペースが半分無駄になる |
| ラベル | アイコンの同じ側、さらに外側に配置:上部のノードのラベルは上に、下部のノードのラベルは下に | アイコンと同じ画面内にあるため、文字を書くためのカメラを再度パンする必要がない |
| フォントサイズ | メイン名 36~40px 楷書、年号はその0.6倍で下部 | フレーミングをズームインすると、画面上で約60~80pxになり、スマートフォンで読める |
カメラ --zoom-fill | 0.70 | ノードが左右に近接しているため、ズームインしすぎると隣のノードが途中で切れてしまう |
| 1駅2シーンの配分 | アイコン描画 0.60 / 「名前 + 年号」の筆記 0.40 | 字幕を書くには十分な時間を確保する必要がある。そうしないと、書き終わる前に切り替わってしまう |
| タイムライン本体 | 隣接する2駅の間に描画し、その駅のアイコンと同じシーンで処理 | そうしないと、軸線が一度に全体として現れ、リズムが途切れる |
Bタイプの3つの重要な注意点:
- テキスト字幕にはインクのバウンディングボックスを使用しない(鉄律19の例外を参照)。ラベルのテキストは region よりも幅が広いことが多く、bbox を適用すると文全体がフレームの外に切り取られます。
- ラベルの region は「メイン名 + 年号」の2行分の高さを確保し、高さは 80~90px にします。メイン名の1行分だけを指定すると、年号が切り取られます。
- 最後は必ずズームアウトして全体の軸を表示すること。タイムラインのナラティブとしての価値は「全体のスパン」にあり、最後の3~5秒をズームアウトしないのは、描かなかったことと同じです。
実測ベースライン(1280×720 / 24fps):8ノード × 2シーン = 16要素、完成品 43.5秒、レンダリング 33秒、
--zoom-fill 0.70 --zoom-max 7 --hand-height 350。
フェーズ 3 · ピクセル単位のアノテーション(最も失敗しやすい。検証が必須)
まず座標を測定し、目視で推定しないこと。実践での教訓:640×360 のプレビュー表示サイズを 1280×720 の実際の座標と勘違いし、2回の座標設定がすべて間違っていました。正しい方法は、色/線検出を使用して実際の bbox を取得することです:
<ENV_PY> scripts/detect_marks.py 03-アノテーション/メイン画像.png ラベル # 赤/オレンジ/青のアクセントの連結成分の bbox
<ENV_PY> scripts/detect_lines.py 03-アノテーション/メイン画像.png ラベル # 濃いグレーの線のクラスターの bbox
人手による視覚的なキャリブレーションが必要な場合は、スケールを重ね合わせます:scripts/grid_ruler.py メイン画像.png グリッド付き.png(100px グリッド。実際のピクセルを示します)。
region のカバレッジセルフチェック(完成品の完全性を決定します):
- すべての region の x 区間は、途切れのない帯を形成する必要があります(重なりは許可されますが、隙間は許可されません)。
- 貫通する主体(ルート、タイムライン)は、いくつかの region によって完全にカバーされる必要があります。そうしないと、完成品で線が途切れます。
- 最後の要素は、個別に小さな領域を選択します(鉄律3を参照)。
03-アノテーション/<名前>.annotation.json を作成します:
{
"sceneId": "scene-01",
"canvas": { "width": 1280, "height": 720 },
"storyBasis": "このシーンの字幕イベントの概要",
"sceneDurationMs": 56000,
"elements": [
{
"id": "ruijin", "label": "出発·瑞金", "sequence": 1,
"narrativeRole": "長征の出発点", "subtitle": "対応する字幕の原文",
"type": "point",
"region": { "x": 265, "y": 515, "width": 145, "height": 100 },
"reveal": { "direction": "left_to_right", "startMs": 0, "durationMs": 8100,
"maskPaddingPx": 16, "protectedRegions": [] },
"handPath": { "start": [275, 570], "end": [400, 575], "easing": "easeInOut" }
}
]
}
canvasはソース画像の実際のピクセルと等しくなければなりません。regionは整数ピクセルで、かつキャンバス内にある必要があります。sequenceはナラティブな順序に従い、視覚的な左から右ではありません。- stream 描画モードでは、
direction/handPathはプレビューツールでのデモンストレーション用にのみ使用され、完成品のペンの軌跡には影響しません。
検証画像を生成し、フレームごとに目視で確認します:
<ENV_PY> scripts/region_sheet.py メイン画像.png アノテーション.json 05-画面収録スクリーンショット/検証-region.png
各セルは、対象の要素を正確に囲む必要があります。空白または別の要素を囲んでいる場合は、座標が間違っているため、前のステップに戻ります。
フェーズ 4 · ナレーションとタイムラインのアライメント
① ナレーション:全体を一度に生成し、文ごとに分割して決して生成しない(2026-11-05 のやり直しの教訓)
"<TTS_PYTHON> # requests がインストールされた任意の Python 3.9+" \
"jimeng-suite/scripts/jimeng_cli.py" audio \
--text "全体のナレーション、1つの段落、句点で文を区切る" \
--model "SeedAudio 1.0" --submode "全能ナレーション" --voice "狒狒_解説" \
--out "xxx-voice.mp3" --outdir "<プロジェクト>/02-字幕とナレーション" --timeout 900
Seed TTS + テキスト読み上げは 2026-10-24 に廃止されました。もう使用しないでください。--textには全体を一度に入力します。1回で 1500 文字以上(約2分の音声)を処理できます。もう3秒の小さなチャンクに分割しないでください。 実践では、56秒のスクリプトを12のセグメントに分割して個別に生成し、その後結合したところ、各セグメントの話す速度/間/アクセントが一貫せず、 聞こえは「でたらめな話し方」であり、結合後のタイムラインと字幕は必然的にずれました。ユーザーは直接不合格と判定しました。- ナラティブな段落(1段落 = 1つの文のグループ)でのみ分割します。1段落 15~40秒、動画全体で 1~3段落に収めます。1段落にできる場合は、2段落にしないでください。
- コンテンツ審査に引っかかる可能性があります:軍事/暴力/危害に関する表現(血戦、殲滅、撃破、敵)は、
JimengContentRejectedErrorを返します。中立的な説明に書き換えてください(「多大な代償を払った」「火力による封鎖の下で」)。年号は中国語の数字(一九三四年)で書くとより安定します。 - 99秒の台詞で、実測 62秒で出力されました(1.19MB / 99.05s)。
② 無音の健康診断とリズム圧縮(必須。v5 のやり直しの教訓)
「全体を一度に TTS に送信する」ことは、プラットフォームが内部で1つの連続したセグメントとして合成したことを意味するわけではありません。Jimeng は長いテキストを内部でチャンクに分割してから結合し、チャンク間に -120dB の絶対無音を挿入します。長征 v4 の実測では、317文字 / 99.05秒 の中で、
31箇所の 0.4秒以上の間が合計 27.2秒(27.5%) あり、そのうち 4.00秒 @14.05、2.60秒 @70.60 の2箇所は
純粋なデジタルゼロサンプルでした(自然な間には背景ノイズがあり、約 -50~-60dB であり、-120dB にはなりません)。
実際の発声はわずか 71.85秒(4.41文字/秒。遅くはありません)でした。「リズムが遅い、変だ」という印象は、すべて空白によって引き起こされていました。
# 1) 健康診断:1.2秒 を超える間はすべて処理する必要があります
ffmpeg -hide_banner -i xxx-voice.mp3 -af "silencedetect=noise=-38dB:d=0.35" -f null - 2>&1 | grep silence_start
# 2) 圧縮:自然な間を段階的に保持し、発声コンテンツには一切触れません
<ENV_PY> scripts/tighten_audio.py 02-字幕とナレーション/xxx-voice.mp3 \
02-字幕とナレーション/xxx-voice-tight.mp3 --report 06-ドキュメント/_tighten.json
tighten_audio.py:50ms RMS ウィンドウ、<-40dB を無音と判定。1.0秒以上の間は 0.55秒 を残し、
0.35~1.0秒の間は 0.32秒 を残し、先頭は 0.12秒 を残し、サンプルに従って再結合します。
v4→v5 の実測では、99.05秒 → 83.15秒 となり、無音の割合は 27.8% → 14.0% に減少し、0.7秒を超える間はなくなりました。
圧縮によりオーディオの長さが変わるため、その後の ASR 書き起こし、タイミング、SRT、ASS、アノテーションの長さ、レンダリングの total-ms はすべて、圧縮後のオーディオに基づいて再実行する必要があります(鉄律25)。
--reuse-asrは字幕の改行調整にのみ使用し、 オーディオが変更された場合は、古いダンプを決して再利用してはいけません。
③ タイムライン:ナレーション自体の書き起こしタイムスタンプを使用して逆算し、無音検出で推測しない
<ENV_PY> scripts/asr_align.py 02-字幕とナレーション/xxx-voice.mp3 06-ドキュメント/スクリプト.txt \
--model small \
--out-timing 03-アノテーション/xxx.annotation.timing.json \
--out-srt 02-字幕とナレーション/xxx.srt --max-chars 18 \
--dump-asr 06-ドキュメント/_asr.json
asr_align.py の動作:ffmpeg で 16k モノラルに変換 → faster-whisper(word_timestamps=True)
→ 文字ごとのタイムラインに展開 → スクリプトの各文を書き起こし全文内で位置特定(正確な検索 → 最初の8文字アンカー → フォールバック)
→ timing.json(segments[i/start/end/text] と互換性あり)と1行のSRTを出力します。
なぜ silencedetect を使用しないのか:無音は「ここに間がある」ことを示すだけです。息継ぎや強調のために間が入ることもあります。
文の数と間の数が一致しない場合は、文字数に基づいて均等に割り当てるしかなく、字幕は必然的にずれます。
asr_align.py はデフォルトで文境界のスナップ(snap_to_voice)も実行します:faster-whisper は長い無音がある場合、
次の文の start を無音が始まった位置にマークする傾向があり、その結果、字幕が早く表示され、視聴者は字を見ながら音声を待つことになります
(v4 では丸4秒早くなりました)。スクリプトは 20ms のグリッドで実際の発声開始点を見つけて修正します。--no-snap で無効にできます。
④ オーディオコンテンツを必ずチェックする(「でたらめな話し方」を判断する唯一のハードな基準):
<ENV_PY> -c "..." # difflib で スクリプト と _asr.json の全文を比較
類似度は 0.8 以上である必要があります。small モデルは中国語の固有名詞に対して同音異義語の誤認識のみを行います(弘/紅、尊/遵、武/呉、鎖/索など)。 構造(文の順序、文の長さ)が一致していれば、読み上げは正しいことを意味します。文全体の欠落、重複、ジャンプが発生した場合 → オーディオ自体に問題があるため、再生成します。
⑤ 字幕の改行に関する3つのルール(すべて asr_align.py::split_subs 内):
- まず句読点で小さな文に分割し、現在のブロックが limit×0.55 未満の場合のみ、次の小さな文と結合する。そうしないと、 「踏上了举世闻名的长征之路那一年」のような、小さな文がまたがって結合されてしまう可能性があります。
- 結合後は limit を超えてはならない(+3 のトレランスを残さないこと。20文字を均等に分割すると「…要走多远只」のような、1文字が次の行に送られることがあります)。
- それでも長い場合のみ、単一のブロックを均等に分割します。末尾の ≤4 文字は前の項目に結合します。
⑥ 最後の余白(鉄律18の具体的な実装方法):最後の station に "tail": 0.30 を追加します。
これにより、そのセグメントの最後の30%には要素が配置されなくなり、レンダラーはこの時間を使用してズームアウトと静止画表示を行います。
実測では、99秒の動画で4秒の余白を取ることで、全体像を見るのに十分な時間が確保されました。
⑦ ストーリーボードの長さ配分:1要素は1文(または1文内の1つの動作)に対応し、durationMs はその文の長さを直接使用します。
セグメント内では ratio に従って「物を描く / 文字を書く」をさらに分割します。scripts/make_annotation_v3.py を参照してください。
フェーズ 5 · レンダリング、合成、検証
<ENV_PY> srt-whiteboard-animation/scripts/render_stream_whiteboard.py \
03-アノテーション/メイン画像.png 03-アノテーション/メイン画像.annotation.json 04-レンダリング出力/xxx-silent.mp4 \
01-素材/hand-55pct.png \
--cap-long-edge 1280 --fps 24 --total-ms <ナレーションのミリ秒>
--cap-long-edgeがソース画像の長辺と等しい場合、スケーリングは行われません。プレビューでは小さくして高速化できます。--total-msにはナレーションの長さ(ミリ秒)を渡し、動画がナレーションの終了より前に終わらないようにします。- 最終行は
OUTPUT=<パス>を出力します。
オーディオトラックの合成:
ffmpeg -y -i xxx-silent.mp4 -i ナレーション.mp3 -c:v copy -c:a aac -b:a 192k -shortest 完成品.mp4
必ず3点をチェックする(各1フレームを抽出):
- 開始時はきれいな黄土色の背景で、線が早期に表示されていないこと。
- 中間:線が連続しており、色が正しいこと(赤/オレンジ/青がすべて存在すること)。
- 最後:全体が完全であり、線の欠落や大きな空白のブロックがないこと。
長さの誤差が 1秒 を超える場合は、--total-ms を調整するか、各 durationMs を微調整して再レンダリングします。
フェーズ 5.5 · カメラと文字書き(v3 の核心。「描いているように見えるか」を決定する)
上流のレンダラーは、1枚の固定キャンバスに最初から最後まで描画します。動画をカメラ言語のように見せるために、render_camera_whiteboard.py サブクラスを追加します。
リポジトリのコードを変更する必要はありません(scripts/render_camera_whiteboard.py は再利用可能です):
<ENV_PY> scripts/render_camera_whiteboard.py 03-アノテーション/メイン画像.png 03-アノテーション/メイン画像.annotation.json \
04-レンダリング出力/xxx-silent.mp4 01-素材/hand.png \
--canvas-long-edge 2560 --frame 1280x720 --fps 24 --grid-edge 20 \
--zoom-fill 0.86 --zoom-max 7 --hand-height 350 --total-ms 56064
これは6つのことを行います:
| メカニズム | 実装 | 理由 |
|---|---|---|
| カメラフレーミング | キャンバス 2560×1440 でレンダリングし、各フレームをビューポートに従ってクリップし、1280×720 にスケーリング | 要素を「フルスクリーンで描画」し、描き終わったらパンして移動できる |
| インクのバウンディングボックスによるフレーミング | ターゲットフレーム =「そのシーンの allowed マスク内の実際のインクの bbox」、region ではない | region は図形よりも一回り大きく、隣接要素にまたがることさえある。そのままフレーミングに使用すると → 画面の中央が空白で、図形は隅に追いやられる(v3 の最初のバージョンでまさにこれに失敗) |
| カメラフォロー | 隣接するシーン間で指数平滑化、tau 0.20/0.30、字幕シーンは pad 0.34、描画シーンは pad 0.05 | 自然にパン効果が生まれ、トランジションフレームを追加する必要がない |
| シーン切り替えバッファ | 各シーンの開始時に 0.22秒(約5フレーム)空回ししてから描画を開始 | カメラが前のシーンから移動するのに 0.3~0.6秒 かかる。この間に描画を開始すると、「ペンが画面外で描画」され、「手は動いているがコンテンツがない」状態の主な原因の1つになる |
| 最後の全体像 | 最後のシーン終了後、ターゲットをキャンバス全体に設定し、--total-ms までフレームを補完 | 「最後に全体像を表示する」 |
| 手の固定画面サイズ | 手はキャンバスに描画せず、代わりに出力フレームに貼り付ける(--hand-height 350) | そうしないと、ズームインしたときに手が画面全体を占めてしまう。430 でもまだ大きすぎることが確認され、書いている文字を隠してしまう |
--zoom-fill はベース画像の密度に応じて個別に設定する(同じパラメータセットですべてをまかなうことはできない):
| ベース画像タイプ | zoom-fill | 理由 |
|---|---|---|
| 疎(ルート図/フローチャート、要素間に大きな余白がある) | 0.86 | 安心してズームインでき、画面が空っぽにならない |
| 密(水平タイムライン、要素が左右に隣接している) | 0.70 | ズームインしすぎると隣の要素が半分に切れてしまい、乱雑に見える |
16:9 のハードな制約:縦長の要素(旗、スマートフォン)は画面の高さによって制限され、フルスクリーンにした場合、縦方向は 60~70%、 横方向は約 25% しか占有できません。これは幾何学的に決定されたものであり、バグではありません。より画面を埋めたい場合は、要素をより幅広く描くか、縦型の動画を出力します。
文字書き(地名/年号を書き出す):
# 1) まずラベルをベース画像に焼き付ける(座標は 1280×720 のアノテーション座標を使用。スクリプトが自動的にフォントサイズに従って2行に配置する)
<ENV_PY> scripts/add_handwriting.py ベース画像2x.png 03-アノテーション/メイン画像-v3.png 02-字幕とナレーション/ラベル.json
# 2) アノテーションでこのシーンに "inkPath": "textwrite" を設定する。region はテキストの枠
textwrite は列ごとに右から左へ進行し、ペン先は公開フロンティアにあります。テキストが必ず書き終わることを保証します(スケルトントラッキングは細いストロークを見逃すため、使用しないでください)。
最初の30%のフレームではペンを保持してカメラが所定の位置に移動するのを待ち、その後書き始めます。そうしないと、文字がフレームの外に書き出される可能性があります。
ラベルのレイアウト:メイン名 34~40px 楷書(C:/Windows/Fonts/simkai.ttf)+ 年号をその0.6倍のフォントサイズで下部に、
色 (58,56,52) は線画と同系色ですが、やや薄めです。
シーンの分割:1駅 = 2シーン。最初に物を描き(0.6の長さ)、続けて文字を書きます(0.4の長さ)。 文字を書くシーンのフレーミングフレームは、自身のテキストフレームです。これにより、カメラがわずかにパンインし、「ペンが横に移動して文字を書いている」ように見えます。
フェーズ 6 · 字幕の焼き付け(SRT → ASS → 動画への焼き付け)
字幕のタイムラインは別途指定しないでください。フェーズ4で生成された .timing.json を直接使用します。これはすでにナレーションと文単位で整列されており、
字幕の文が切り替わる瞬間がペンが停止する瞬間であり、これこそが「リズムの同期」です。
# 1) timing.json -> SRT(**1行**、長すぎる場合は自動的に複数の連続した字幕に分割)
<ENV_PY> scripts/make_srt.py 03-アノテーション/メイン画像.annotation.timing.json 02-字幕とナレーション/x.srt \
--max-chars 14
# 2) SRT -> ASS(明示的な PlayRes + \pos で下端にアンカー、フォントサイズ/位置は実際のピクセル)
<ENV_PY> scripts/srt2ass.py 02-字幕とナレーション/x.srt 02-字幕とナレーション/x.ass \
--play-res 1280x720 --font-name "Microsoft YaHei" --font-size 46 --margin-v 36 --single-line
--single-line が有効な場合、scripts/srt2ass.py は SRT の max-chars を超える行を
「次の行の start = 前の行の end」で分割し、隙間なく連続して表示します。1行の制限を超える字幕に対しては、間を挿入しないでください。
ASS を.mp4に焼き付ける:
ffmpeg -y -i 完成品.mp4 -vf "ass=02-字幕とナレーション/x.ass" -c:a copy 完成品_字幕付き.mp4
最終納品物チェックリスト
- ナレーション付き MP4:納品物フォルダ内にある
- 5枚以上のキースクリーンショット、カバー範囲:
- ソース画像(フェーズ2の出力)
- アノテーション検証画像(
region_sheet.pyの出力) - レンダリングコンソール(レンダリングスクリプトの実行中のスクリーンショット)
- 完成品のキーフレーム(動画内の重要な瞬間をキャプチャ)
- (オプション)画面収録のフルプロセス
- 画面収録:レンダリングと焼き付けのフルプロセスをキャプチャ(
-t <秒>で自然終了) - すべてのスクリーンショットが
05-画面収録スクリーンショット/フォルダにある - difflib の比較結果が、スクリプトと書き起こし間の類似度 >0.8 を示している
-
--total-msが圧縮後のナレーションの長さと一致している(ミリ秒単位) - 完成品の長さとナレーションの長さの差が 1秒未満である