0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeで会社のURLからPVを作る手順(BGMは聴かずに作曲、Gemini TTSはスキル化)

0
Posted at

Claude Opus 5.5が出てから、Claude界隈はモーショングラフィックのPV作りでざわついています。
モーショングラフィックは、写真や文字、図形を動かして見せる映像表現です(PVはプロモーションビデオの略)。
手元でも試したくなって、お客様の会社の紹介PVを作りました。

この記事は、会社のWebサイトのURLから紹介PVを作るまでの手順をまとめたものです。

シナリオ、動画、BGMはClaude Code(Anthropic社のAIに、手元のパソコンでファイル操作やコマンド実行までさせられるツール)が作ります。

ナレーションは、GoogleのGemini TTS(テキストを人の声で読み上げる音声合成)をClaude Codeのスキルにして生成します。
VOICEVOXなど、ほかの音声合成でも作れます。
スキルは、作業の手順書と道具(スクリプト)をひとまとめにしてClaude Codeに持たせる仕組みです。
決まったフォルダに置くと、依頼の内容に応じて自動で使われます。

BGMは、音を聴けないClaudeが周波数の分布を測りながら作曲します。
使ったコードは、最後の付録にすべて載せています。

PVの8シーンとエンドカード

できあがるもの

今回作ったPVの仕様は次のとおりです(上の画像は、社名、所在地、人物をぼかしています)。

  • 1920×1080(フルHD)、30fps(1秒あたり30コマ)、41.6秒、約16MB
  • ファイル形式はMP4(映像はH.264、音声はAAC。どちらも広く使われている圧縮方式で、パソコンでもスマホでも再生できる)
  • 素材は会社のWebサイトに掲載されている写真とロゴだけ
  • ナレーションはGemini 3.8 Flash TTS
  • BGMは、Claudeがnumpy(Pythonで数値計算をするためのライブラリ)で合成したオリジナル曲

人とClaudeの分担は次のとおりです。

手順 人がやること Claudeがやること
準備 スキルのファイルを置き、APIキーを登録する (なし)
1. シナリオ URLを渡し、確認事項に答える サイトを読み、ナレーションとシーン割りを書く
2. ナレーション 声と読み方を決める gemini-ttsスキルで音声を生成する
3. 文の区切り (なし) 文字起こしで各文の時刻を求める
4. 画像 使ってよい画像か確かめる サイトから画像を集め、記載と照らし合わせる
5. 動画 仕上がりを見る 静止画で確かめてから全編を書き出す
6. BGM 聴いて確かめる 作曲、合成、ナレーションとの音量調整
7. スキル化 スキルにする範囲を決める 会話の履歴から手順を拾い、スキルにまとめる

準備

環境

項目 バージョン 何に使うか
Claude Code デスクトップアプリ、モデルはClaude Opus 5.5 作業全体
OS macOS 27.2(Apple シリコン) Apple シリコンは、Apple独自設計のCPU(M1以降)
Python 3.14.6 スクリプトを動かすプログラミング言語
Pillow 12.3.0 Pythonで画像を加工するライブラリ。動画の1コマずつの絵を描く
numpy 2.4.4 Pythonで数値計算をするライブラリ。BGMの波形の合成と、音の分析に使う
ffmpeg 8.1.2 動画や音声の変換、合成、測定をするコマンドラインのツール
mlx-whisper 0.4.3(モデルはwhisper-large-v3-turbo) 音声の文字起こし。whisperはOpenAIの音声認識モデルで、mlx-whisperはそれをApple シリコンで速く動かす版
フォント ヒラギノ角ゴシック(macOS標準) テロップの文字

gemini-ttsスキルを入れる

gemini-ttsは、Gemini TTS(モデル名は gemini-3.8-flash-tts)で、テキストから音声ファイル(WAV形式)を作るスキルです。
Pythonに最初から入っている機能(標準ライブラリ)だけで動くので、追加のインストールは要りません。

GeminiはAPI(プログラムから外部のサービスを呼び出す窓口)経由で使うので、APIキー(呼び出す人を識別するための鍵となる文字列。パスワードと同じく他人に見せない)を用意します。

  1. 付録Fの2ファイルを、~/.claude/skills/gemini-tts/SKILL.md と ~/.claude/skills/gemini-tts/scripts/tts.py に置く
  2. Google AI Studio(GoogleのAIをブラウザで試したり、APIキーを発行したりできるサイト)のAPI Keysページで、APIキーを作る。制限を付けるなら「Restrict to Gemini API only」を選ぶ
  3. ~/.zshrc に export GEMINI_API_KEY=... を書き、source ~/.zshrc で読み込む。~/.zshrc は、macOSのターミナル(zsh)が起動するたびに読む設定ファイルで、ここに書いた値が環境変数(プログラムが読み取れる設定値)になる
  4. Claude Codeのセッション(1回分の会話)を開き直す。キーを登録する前に起動したセッションには、キーが入らない
  5. 設定を確かめる。キーの値は画面に出さない
[ -n "$GEMINI_API_KEY" ] && echo 設定あり || echo なし

無料枠と有料枠

APIの料金は、Google AI Pro(GeminiアプリなどのGoogleの有料プラン)などのサブスクとは別です。
公式ドキュメントに書かれている仕組みは次のとおりです。

  • 無料枠か有料枠かは、APIキーではなくプロジェクト(Google Cloud上で、APIキーや課金の設定をまとめる単位)ごとに決まる。プロジェクトにCloud Billing(Google Cloudの支払い設定)がつながっていれば有料枠になる
  • 無料枠では、送ったテキストと生成物がGoogleの製品改善に使われる。有料枠では使われない
  • サブスクの特典はAI StudioのWeb画面の中だけに効き、APIキーでの利用は別に課金される
  • Google AI Proの加入者には、Google Developer Program(Googleの開発者向け会員制度)経由で月10ドルのGoogle Cloudクレジット(利用料に使える割引分)が付き、有料枠の利用料に先に充てられる。受け取るときは、APIのプロジェクトがつながっている請求先アカウント(支払い方法を登録したアカウント)を選ぶ
  • 有料枠の単価は、出力音声100万トークンあたり9ドル(2027年1月から18ドル)。トークンは、AIが扱うデータの量を数える単位
  • 2026年3月23日から、有料枠の課金は既定でプリペイド(先に入金した残高から引かれる方式)になった。プリペイドが要るのは有料枠に上げるときだけで、新しいアカウントは無料枠から始まる

音声1秒を32トークンとすると、40秒のナレーションは約0.012ドルになります。
32トークンは音声理解の公式な換算値で、TTSの出力にも当てはまるかは確かめていません。

無料枠のキーを作るときは、AI StudioのProjectsページで新しいプロジェクトを作り、課金(Set up billing)を設定しないままキーを作ります。
Google Cloudのコンソールで作ったプロジェクトは、同じページの「Import projects」で取り込めます。

課金を設定済みのプロジェクトは、課金を無効にすれば無料枠に戻ります。
公開済みのWebサイトの文章を読ませる程度なら無料枠で足りますが、未公開の原稿を扱うなら有料枠が向いています。

今回の環境では、無料枠のキーを作れず、有料枠のキーを使っています。
原因は特定できていません。上の無料枠の作り方は公式ドキュメントの記述で、この環境では試していません。

手順1 サイトからシナリオを作る

Claude Codeに、URL、テーマ、尺(動画の長さ)、文の数を渡します。
今回送ったプロンプト(Claudeへの指示文)は次のとおりです(URLだけ置き換えています)。

https://www.example.co.jp
このホームページの内容を元に30秒の会社のプロモーション動画を作成したい。
テーマ:会社紹介
ナレーション原稿を8文程度でかいて、1文ごとに「画面に何を出すか」のシーン割利もつけたシナリオを作成して。

出典の確認まで頼むなら、次の形にすると後の手順が楽になります。

<URL>
このホームページの内容を元に、<N>秒前後の会社紹介動画のシナリオを作って。
ナレーションは<M>文程度。1文ごとに「画面に何を出すか」と「テロップ」も付けて。
数字や固有名詞は、どのページのどの記載から取ったかを添えて。

Claudeは、トップページから会社案内、選ばれる理由、設備一覧、製品実績などのページをたどります。
そのうえで、ナレーションとシーン割り(場面ごとに何を映すかの割り当て)を書きます。
トップページだけでは、設備の型番や製品の寸法がわからないためです。
今回の出力は次の8文でした。

# ナレーション 画面に出すもの
1 静岡県〇〇市。精密切削加工のA社です。 新工場の外観
2 光学機器や医療機器の、新しい製品づくりを支えています。 光学部品と医療機器部品の物撮り
3 扱うのは、アルミや銅、ステンレスなどの非鉄金属。 材料と加工中のアップ
4 厚さ0.3ミリのフィンも、一個から削り出します。 ヒートシンクのマクロ撮影
5 開発や試作の段階から、最適な加工方法を提案します。 図面を前にした打ち合わせ、3Dプリンター
6 三次元測定機とマイクロスコープで、品質を一つずつ確かめます。 測定機のプローブ、マイクロスコープの画面
7 新工場で、30代を中心としたチームが挑み続けています。 社員の表情、協働ロボット
8 (経営理念)。A社。 集合写真からロゴへ

シナリオを受け取ったら、次の3点を確かめます。

  • 数字の出典。今回は「0.3ミリ」が製品実績ページのヒートシンク(熱を逃がすための部品。フィンと呼ぶ薄い板が並ぶ)の備考、「一個から」が選ばれる理由のページから取られていた
  • Claudeが外した情報。今回は、取引先の社名(宣伝に使うには先方の許可が要る)、言い方を誤ると誇大表現になりやすい受賞関係の実績、古い従業員数が外されていた
  • Claudeが挙げた確認事項。今回は「部品を物撮り(商品を撮影すること)してよいか」と「『30代中心』は今も正しいか」を聞かれ、次の一行で答えた
2、4はそのまま使用して問題なし。7は現状も同じ。

手順2 ナレーション音声を作る

シナリオの各文を1行ずつ書いた原稿ファイルを用意し、gemini-ttsスキルで読ませます。

/gemini-tts
narration.txt を読み上げて。声は Kore、スタイルは "calm, warm and trustworthy corporate narration, moderate pace"。

スキルは、音声の narration.wav と、長さや声を記録した narration.json(JSONはデータを書き留める形式の一つ)を出力します。
声は30種類から選べます(一覧は付録FのSKILL.mdにあります)。
スタイルは読み方の指示で、話し方の雰囲気や速さを英語で書きます。

尺はTTSの読む速さで決まります。
今回は30秒のつもりで8文(約200字)にしましたが、音声は39.6秒になりました。
動画は音声に合わせて延ばすので、最初から「尺は音声に合わせてよい」と伝えておきます。

今回のPVのナレーションは、AI StudioのWeb画面で同じモデルを使って生成したものです。
スキルでも、8文程度の原稿を一度に読ませてナレーションを作れることを確かめています。
原稿を1行ずつ生成して各行の時刻をJSONに出す --split は、APIの応答を偽物に差し替えたテストでしか確かめていません。

ほかの音声合成を使う場合

この手順ではGemini 3.8 Flash TTSを使いましたが、音声合成はほかのものに替えられます。
手順3以降に必要なのは、ナレーションの音声ファイル(WAVなど)だけだからです。

たとえばVOICEVOX(無料で使える日本語の音声合成ソフト)は、手元のパソコンで動くので、APIキーも利用料も要りません。
VOICEVOXを起動しておくと、音声を作るエンジンが http://localhost:50021 で呼び出しを受け付けます。
Claude Codeに「VOICEVOXのエンジンで narration.txt を読み上げて、WAVで保存して」と頼めば、この窓口を使って音声を作れます。

使うときは、利用規約を確かめてください。
VOICEVOXは商用、非商用を問わず使えますが、VOICEVOXを使ったことがわかるクレジット表記が必要です。
さらに、声(キャラクター)ごとにも規約があります。

手順3 文の区切りの時刻を求める

テロップ(画面に重ねて出す文字)とシーンの切り替えを読み上げに合わせるため、各文の開始と終了の時刻を求めます。

ffmpegの無音検出(silencedetect)だけでは、読点の間と文の間を区別できません。
今回のナレーションでは、無音が21か所見つかりました。
そこで、mlx-whisper(Apple シリコン向けのwhisper)で単語ごとの時刻を取ります(コマンドは付録B)。
文字起こしは多少の誤変換(「光学機器」が「工学機器」になるなど)があっても、時刻を取る用途には支障がありません。

シーンの切り替え位置は、文と文の間の無音の中央に置きます。
今回は、音声上の時刻で4.93秒、10.15秒、15.03秒、19.25秒、24.06秒、29.80秒、34.66秒、37.27秒でした。
動画の冒頭には0.4秒の間を入れるので、映像上の時刻はそれぞれ0.4秒ずつ後ろになります。

--split で生成した音声なら、narration.json の時刻をそのまま使えます(上の注意書きのとおり、実際のAPIでは未検証です)。

手順4 サイトの画像を集める

手順5のプロンプトに「ホームページの画像も使って」と書くと、Claudeが画像を集めます。
Webページの画像は、HTML(ページの文章と構造)の画像タグ <img> か、CSS(ページの見た目の設定)の背景指定 url() に書かれています。
Claudeはこの両方から画像のURLを一覧にしてダウンロードし(今回は70枚)、縮小一覧(コンタクトシート)を作って中身を確かめます。

テロップに出す材質名なども、製品実績ページの記載と照らし合わせます。
今回は、土台の部品がA5052、LEDを固定する部品がC1020、筒状の部品がSUS316Lでした。
どれも材料の規格記号で、順にアルミ合金、純度の高い銅、ステンレスです。

人が確かめることは次の3点です。

  • 画像を動画に使ってよいか。サイトの写真でも、使ってよいかは事前に先方に確かめる
  • 写真と読み上げの内容が合っているか。今回は「マイクロスコープ」の場面で、サイトの人物写真に写っていたのが実体顕微鏡(両目でのぞく低倍率の顕微鏡)だったため、Claudeが設備一覧のデジタルマイクロスコープ(拡大した像をカメラで撮ってモニターに映す顕微鏡)の写真を使った
  • 解像度。今回のサイトの写真は幅750〜1000pxが多く、1920pxの画面いっぱいに引き伸ばすとぼける。そのため手順5では、写真を全画面ではなくカードとして置いた

手順5 動画を書き出す

ナレーション音声を渡して、動画を頼みます。
今回送ったプロンプトは次のとおりです(ファイル名だけ置き換えています)。

@ナレーション.wav
シナリオを元にナレーション用の音声ファイルを作りました。
ホームページの画像も使って、シナリオに沿ったモーショングラフィック動画をプロモーションビデオとして作成してください。

尺と確認の仕方も指定するなら、次の形にします。

@<ナレーション>.wav
この音声に合わせて、ホームページの画像を使ったモーショングラフィック動画を作って。
尺は音声に合わせてよい。全編を書き出す前に、各シーンの静止画を見せて。

今回のPVで、Claudeが作った画面の構成は次のとおりです。

  • 写真は白いフチと影を付けたカードにし、背景にはサイトの白地に緑の曲線の画像(幅4000px)か、同じ写真を強くぼかして暗くしたものを敷く
  • ブランドカラー(その会社のロゴなどに使われる決まった色)は、ロゴ画像の色を集計して取る(今回は緑の #1E964D とグレーの #595A5C。#で始まる値は色を表すコード)
  • テロップは手順3の単語の時刻に合わせて出す。たとえば「アルミ」11.14秒、「銅」12.16秒、「ステンレス」12.68秒と間が約0.5秒しかない箇所は、文字を0.25秒で出し切る
  • シーンの切り替えは、ブランドカラーの斜めの帯が横切る0.4秒のワイプ(画面を拭うように次の場面へ移る切り替え効果)にし、文と文の間の無音の中央に置く
  • 写真のゆっくりしたズームは、Pillowで画像の拡大縮小をする resize() の box 引数に小数の座標を渡して、1ピクセル未満の単位で切り出す(整数座標だと絵がカクつく)
bw, bh = self.cw / zoom, self.ch / zoom
box = (self.cx - bw / 2, self.cy - bh / 2, self.cx + bw / 2, self.cy + bh / 2)
photo = self.src.resize((self.w, self.h), Image.BICUBIC, box=box)

描画はPillowで1フレーム(1コマ)ずつ行います。
描いた絵は、圧縮していない画素のデータのまま、パイプ(プログラムの出力を別のプログラムへ直接流す仕組み)でffmpegに渡します。
書き出しは次の順で進めます。

  1. 静止画を書き出して確かめる(python3 render.py preview 4.6 9.5 14.2 ...)。今回はこの段階で、4シーン目の「mm」が画面の右端で切れていることと、7シーン目の下半分が空きすぎていることが見つかった
  2. 直してから全編を書き出す(python3 render.py render promo.mp4)。今回は1,248フレームで、手元のMacで約45秒だった
  3. ffprobe(ffmpegに付属する、動画ファイルの中身を調べるツール)で尺と、映像と音声のトラックがそろっていることを確かめ、完成した動画からフレームを抜き出してテロップのタイミングを確かめる

付録Cのrender.pyにある時刻(CUTS や T(11.10) など)は今回のナレーション専用の値なので、手順3で求めた時刻に置き換えます。

手順6 BGMを付ける

最後にBGMを頼みます。
今回は、動画を受け取ったあとで次のように聞きました。

これBGMつけられない?

既存の曲を使うとライセンスの確認が要るので、Claudeはnumpyで作曲から合成までを行います(付録D)。

今回の曲は、120BPM(1分間に120拍の速さ)の長調(明るい響きの調)で、コード進行(和音の並び)はD、A、Bm、Gの繰り返しです。
楽器は、ピアノ風のアルペジオ(和音を1音ずつ順に鳴らす弾き方)、パッド(背景で長く鳴る柔らかい和音)、ベース、軽いドラムです。
冒頭4秒はドラムを入れず、エンドカード(最後にロゴを出す画面)の38秒で主和音(曲の中心になる和音)に落ち着いて、フェードアウトします。

Claudeは音を聴けません。
そこで耳の代わりに、スペクトログラム(音の高さごとの強さを、時間に沿って色で描いた図)と、帯域(音の高さの範囲)ごとのエネルギーを測って調整します。

下の表のdBは全体に対する比で、0に近いほどその帯域の音が大きく、マイナスが大きいほど小さいことを表します。
kHz(キロヘルツ)は音の高さの単位で、数字が大きいほど高い音です。

今回は、最初の合成で12kHz以上の高い音が全体に対して-7.2dBもあり、ハイハット(シャンシャンと刻むシンバル)の音が強すぎました。
Claudeはハイハットとクラップ(手拍子の音)の高さの範囲を絞りました。
次に低い音が大半を占めたので、キック(ドンと鳴る低い太鼓)とベースを下げています。

帯域(10〜20秒の区間) 初回 ハイハットの帯域を制限 キックとベースを下げる
0〜250Hz -2.3dB -1.3dB -2.5dB
250Hz〜2kHz -7.1dB -6.0dB -3.8dB
2〜6kHz -20.4dB -21.5dB -19.3dB
6〜12kHz -16.7dB -25.6dB -23.4dB
12〜24kHz -7.2dB -56.5dB -54.3dB

ナレーションとの合成は、次の2点を目安にします(コマンドは付録E)。

  • ffmpegの sidechaincompress(別の音が鳴っている間だけ音量を下げるフィルタ)で、ナレーションが鳴っている間だけBGMを下げる。この処理をダッキングと呼ぶ。台詞中の声とBGMの差は12〜16dBほど取る。今回は最初の設定で8.6〜12.2dBしかなく、BGMをさらに4dB下げて12.8〜16.4dBにした
  • 全体の音量をLUFSでそろえる。LUFSは人が感じる音の大きさの単位で、放送局や動画配信サービスは、この値で番組ごとの音量をそろえている。目安は、話し声が中心の配信なら-16 LUFS前後、YouTubeなら-14 LUFS前後
  • 今回は元のナレーションが-22.8 LUFSと小さめだったので、リミッター(音が一定の大きさを超えないように抑える処理)で最大音量を抑えながら、-16.1 LUFSまで持ち上げた

数値で合わせられるのは、ここまでです。
曲の雰囲気が合っているか、BGMの音量が聴いて心地よいかは、人が再生して確かめます。

手順7 スキルにして繰り返し使う

ここまでの手順を一通り終えたら、同じ会話の最後で /skill-creator を使うと、この流れをスキルにできます。
skill-creatorは、スキルを作るためのスキルです。
会話の履歴から、使ったツール、作業の順序、途中で入れた修正を拾い、SKILL.mdにまとめます。

/skill-creator この会話の、サイトのURLから紹介PVを作る流れをスキルにして

skill-creatorは、作り始める前に作り方を確かめてきます。
ほかのサイトにも使えるスキルにするなら、次のように答えます。

  • 1本のPV専用に書いた動画のプログラムは、場面の構成を設定ファイルで渡せる汎用の形に作り直す
  • 画面の形は、横長と縦長の両方に対応させる
  • 音量の標準を決める(YouTubeに載せるなら-14 LUFS)
  • 題材にしたのとは別のサイトで、1本通しで作って確かめる

できあがるスキルは、手順書のSKILL.mdと、次のようなスクリプトの組み合わせになります。

スクリプト 役割
collect_images.py サイトの各ページから画像とロゴを集め、縮小一覧を作る
word_times.py ナレーションを文字起こしし、文の区切りとシーンの切り替え位置を求める
render.py 場面の構成を書いた設定ファイル(scenes.json)から、動画を1コマずつ描いて書き出す
bgm.py 長さ、テンポ、調を指定してBGMを作曲し、音の高さの範囲ごとのエネルギーを測る
mix.py ナレーションとBGMを合成し、全体の音量をそろえ、声とBGMの音量差を報告する

スキルにしておけば、以後は「このURLからPVを作成して」と頼むだけで、画像集めからBGMまでを同じ流れで進められます。
ただし、次の2か所では、Claudeが手を止めて人の返事を待ちます。
サイトの事実や権利、音の聴こえ方は、Claudeには判断できないためです。

  • シナリオができたところで、確認事項(写真を使ってよいか、今も正しい記載か、料金の条件、連絡先を載せるかなど)に答え、シナリオを承認する
  • 最後に、BGM入りの動画を聴いて確かめる

スキルを使うときに気をつけること

  • 別のサイトで1本作ると、題材にしたサイトでは起きなかった問題が見つかる。ロゴ画像の形、HTMLの書き方、ナレーションの音量はサイトや音声合成ごとに違うためで、見つかった問題はその会話でClaudeに直させる
  • スキルは、画像集めをシナリオより先に行う。画面に出す写真は、サイトに実在するものでしか決められないため
  • Claude Codeを開いているフォルダがgitのリポジトリ(変更履歴を管理しているフォルダ)なら、スキルは一時フォルダで作業する。試作の画像や動画がリポジトリに混ざり、誤って公開するのを防ぐため
  • 文の区切りを求めたあとは、Claudeが表示する聞き取りの結果と原稿を1文ずつ見比べる。英字の製品名がカタカナで聞き取られるなどの聞き違いで、切り替えの位置がずれることがある

振り返り

聴けない音源を、スペクトログラムと帯域ごとのエネルギーを測って調整する。
こういう進め方は初めて見ました。
ハイハットのシャーという音を「12kHz以上が-7.2dB」という数字で見つけてきたときは、おおっと思いました。

それ以上に感じたのは、Opus 5.5のモーショングラフィックを作る力のすごさです。
こちらが渡したのは、URLとナレーション音声、あとは数回の短い返事だけでした。
それで、読み上げに合わせてテロップが出る41.6秒のPVができあがります。
全編の書き出しも、手元のMacで約45秒でした。

ここまで手軽になると、YouTubeのショート動画は、こうして作られた動画であふれていくと思います。
見た目だけ整った中身の薄い動画が大量に流れる、いわゆるスロップ(AIで大量生産された質の低いコンテンツ)の現象が、動画でも起きるのではないか。
今回PVを作ってみて、いちばん強く残ったのはその予感でした。

付録:全コードと再現手順

A. サイトの画像を集める

各ページのHTMLを取得し、<img> タグとCSSの url() から画像のURLを一覧にします。

画像URLの一覧を作るコマンド
mkdir -p html img
cd html
for p in "" precision technology quality reverse-engineering elected facility product company recruit; do
    n=${p:-top}
    curl -sL -A "Mozilla/5.0" "https://www.example.co.jp/$p/" -o $n.html
done

python3 - <<'EOF'
import re, glob
seen = {}
for f in sorted(glob.glob('*.html')):
    h = open(f, encoding='utf-8', errors='ignore').read()
    for m in re.finditer(r'<img[^>]*>', h):
        t = m.group(0)
        src = re.search(r'(?:data-src|src)="([^"]+)"', t)
        alt = re.search(r'alt="([^"]*)"', t)
        if src and not src.group(1).startswith('data:'):
            seen.setdefault(src.group(1), (f, alt.group(1) if alt else ''))
    for m in re.finditer(r'url\(["\']?([^)"\']+\.(?:jpe?g|png|webp))', h):
        seen.setdefault(m.group(1), (f, '(css bg)'))
for u, (f, a) in seen.items():
    print(f"{f:22s} {a[:30]:30s} {u}")
EOF

一覧とalt属性を見て、使う画像をダウンロードします。
トップのメインビジュアルなどはCSSの背景画像なので、テーマのCSS(style_base.css、style_skin.css)の url() も調べます。

画像をダウンロードするコマンド
cd ../img
B=https://www.example.co.jp/wp-content/themes/example/images
for n in logo.jpg access.jpg back_image.jpg message_1.jpg message_2.jpg message_3.jpg recruit_back.jpg technology_main.jpg top_back_processing.jpg top_back_technology.jpg top_back_quality.jpg top_back_strong.jpg top_back_recruit.jpg top_processing.jpg top_facility.jpg top_company.jpg elected-main.jpg elected-GoAction.jpg elected-1.jpg elected-2.jpg elected-3.jpg elected-4.jpg facility-01.jpg facility-02.jpg facility-03.jpg facility-04.jpg facility-05.jpg facility-06.jpg facility-07.jpg facility-08.jpg precision-1.jpg precision-2.jpg precision-3.jpg precision-4.jpg precision-circle-3.jpg product-01.jpg product-02.jpg product-03.jpg product-04.jpg product-05.jpg product-06.jpg product-07.jpg product-08.jpg product-09.jpg product-10.jpg product-11.jpg product-12.jpg product-13.jpg quality_circle-1.jpg quality_circle-2.jpg quality-1.jpg quality-2.jpg quality-3.jpg quality-4.jpg quality-5.jpg recruit-1.jpg recruit-2.jpg recruit-3.jpg recruit-4.jpg recruit-5.jpg recruit-6.jpg recruit-7.jpg recruit-8.jpg recruit-9.jpg technology_circle-1.jpg technology_circle-2.jpg technology-1.jpg technology-2.jpg technology-3.jpg; do
    curl -sfL -A "Mozilla/5.0" "$B/$n" -o "$n" || echo "fail $n"
done
# トップページの外観画像(アップロード領域にある)
curl -sfL -A "Mozilla/5.0" "https://www.example.co.jp/wp-content/uploads/2022/12/%E5%A4%96%E8%A6%B32.png" -o gaikan2_full.png

B. 文の区切りの時刻を取る

無音検出だけでは読点の間と文の間を区別できないので、単語ごとの時刻を文字起こしで取ります。

無音検出と文字起こしのコマンド
# 無音の区間を出す(候補が多すぎて、これだけでは文の区切りを決められない)
ffmpeg -hide_banner -i narration.wav -af silencedetect=noise=-35dB:d=0.25 -f null - 2>&1 | grep -E "silence_(start|end)"

# mlx-whisper で単語ごとの時刻を出す(Apple シリコン向け)
python3 -m venv venv
./venv/bin/pip install mlx-whisper
./venv/bin/python -c "
import mlx_whisper, json
r = mlx_whisper.transcribe('narration.wav', path_or_hf_repo='mlx-community/whisper-large-v3-turbo', language='ja', word_timestamps=True)
json.dump(r, open('asr.json', 'w'), ensure_ascii=False)
for s in r['segments']:
    print(f\"{s['start']:6.2f}-{s['end']:6.2f} {s['text']}\")
    print('   ', ' '.join(f\"{w['word']}@{w['start']:.2f}\" for w in s['words']))
"

C. 動画のレンダラー

img/ に画像、同じフォルダに narration.wav を置いて実行します。
CUTS や T(11.10) などの時刻は、今回のナレーション専用の値です。

python3 render.py preview 4.6 9.5 14.2   # 指定した秒の静止画を書き出す
python3 render.py render promo.mp4  # 全編を書き出す
render.py(全文)
render.py
#!/usr/bin/env python3
"""A社 会社紹介モーショングラフィック動画のレンダラー

使い方:
    python3 render.py preview 1.0 12.5 ...   # 指定秒のフレームを preview_*.png に書き出す
    python3 render.py render out.mp4         # 全編をレンダリングして音声と合成する
"""
import subprocess
import sys
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont

BASE = Path(__file__).resolve().parent
IMG = BASE / "img"
AUDIO = BASE / "narration.wav"

W, H, FPS = 1920, 1080, 30
LEAD = 0.4           # 音声の前に入れる無音(秒)
TOTAL = 41.6         # 音声 39.56 秒 + リード + エンドカード保持
MX, MY = 96, 60      # セーフマージン

GREEN = (30, 150, 77)
LIME = (141, 198, 63)
GRAY = (89, 90, 92)
DARK = (40, 42, 44)
WHITE = (255, 255, 255)

F_W3 = "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc"
F_W6 = "/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc"
F_W8 = "/System/Library/Fonts/ヒラギノ角ゴシック W8.ttc"

_font_cache = {}


def font(path, size):
    key = (path, size)
    if key not in _font_cache:
        _font_cache[key] = ImageFont.truetype(path, size)
    return _font_cache[key]


def T(a):
    """音声上の時刻を動画上の時刻に変換する"""
    return LEAD + a


# 文と文の間の無音の中央(音声時刻)。ここでシーンを切り替える
CUTS = [T(x) for x in (4.93, 10.15, 15.03, 19.25, 24.06, 29.80, 34.66, 37.27)]
TRANS = 0.4


def clamp(v, lo=0.0, hi=1.0):
    return max(lo, min(hi, v))


def ease_out(p):
    p = clamp(p)
    return 1 - (1 - p) ** 3


def ease_in_out(p):
    p = clamp(p)
    return 4 * p ** 3 if p < 0.5 else 1 - (-2 * p + 2) ** 3 / 2


def lerp(a, b, p):
    return a + (b - a) * p


# ---------------------------------------------------------------- 部品の生成

def text_img(text, fpath, size, fill, shadow=False):
    f = font(fpath, size)
    l, t, r, b = f.getbbox(text)
    pad = 16 if shadow else 2
    img = Image.new("RGBA", (r - l + pad * 2, b - t + pad * 2), (0, 0, 0, 0))
    if shadow:
        sh = Image.new("RGBA", img.size, (0, 0, 0, 0))
        ImageDraw.Draw(sh).text((pad - l + 2, pad - t + 3), text, font=f, fill=(0, 0, 0, 160))
        img = Image.alpha_composite(img, sh.filter(ImageFilter.GaussianBlur(6)))
    ImageDraw.Draw(img).text((pad - l, pad - t), text, font=f, fill=fill)
    return img


def chip(text, fpath, size, fg, bg, padx=28, pady=16, radius=10, accent=None):
    f = font(fpath, size)
    l, t, r, b = f.getbbox(text)
    acc_w = 14 if accent else 0
    w = r - l + padx * 2 + acc_w
    h = size + pady * 2
    img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    d = ImageDraw.Draw(img)
    d.rounded_rectangle((0, 0, w - 1, h - 1), radius=radius, fill=bg)
    if accent:
        d.rounded_rectangle((0, 0, acc_w + radius, h - 1), radius=radius, fill=accent)
        d.rectangle((acc_w, 0, acc_w + radius, h - 1), fill=bg)
    # 字面の縦位置をボックス中央に合わせる
    d.text((padx + acc_w - l, (h - (b - t)) / 2 - t), text, font=f, fill=fg)
    return add_shadow(img, blur=10, offset=(0, 6), opacity=70)


def add_shadow(img, blur=16, offset=(0, 10), opacity=90):
    m = blur * 2
    w, h = img.size
    canvas = Image.new("RGBA", (w + m * 2, h + m * 2), (0, 0, 0, 0))
    a = img.getchannel("A").point(lambda v: v * opacity // 255)
    sh = Image.new("RGBA", img.size, (0, 0, 0, 255))
    sh.putalpha(a)
    canvas.paste(sh, (m + offset[0], m + offset[1]), sh)
    canvas = canvas.filter(ImageFilter.GaussianBlur(blur))
    canvas.alpha_composite(img, (m, m))
    canvas.info["shadow_margin"] = m
    return canvas


def telop(text, size=56):
    """下部テロップ:緑の帯+ライムのアクセント"""
    return chip(text, F_W6, size, WHITE, GREEN + (240,), padx=40, pady=24, radius=8, accent=LIME)


def keyed_logo(width):
    """ロゴJPEGの白地を透過にする"""
    src = Image.open(IMG / "logo.jpg").convert("RGB")
    src = src.resize((width, round(src.height * width / src.width)), Image.LANCZOS)
    arr = np.asarray(src).astype(np.float32)
    mn = arr.min(axis=2)
    a = np.clip((255 - mn) / 255 * 1.6, 0, 1)
    rgb = np.where(a[..., None] > 0.01, (arr - (1 - a[..., None]) * 255) / np.maximum(a[..., None], 0.01), 0)
    out = np.dstack([np.clip(rgb, 0, 255), a * 255]).astype(np.uint8)
    return Image.fromarray(out, "RGBA")


def logo_bug():
    logo = keyed_logo(250)
    w, h = logo.width + 36, logo.height + 24
    img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    ImageDraw.Draw(img).rounded_rectangle((0, 0, w - 1, h - 1), radius=10, fill=(255, 255, 255, 235))
    img.alpha_composite(logo, (18, 12))
    return add_shadow(img, blur=8, offset=(0, 4), opacity=50)


# ---------------------------------------------------------------- 背景

class BrandBG:
    """サイトの白地+緑スウッシュ画像をゆっくり流す"""

    def __init__(self, name="top_back_strong.jpg", flip=False):
        src = Image.open(IMG / name).convert("RGB")
        if flip:
            src = src.transpose(Image.FLIP_LEFT_RIGHT)
        sw = int(W * 1.08)
        self.src = src.resize((sw, round(src.height * sw / src.width)), Image.LANCZOS)
        if self.src.height < H * 1.04:
            sh = int(H * 1.08)
            self.src = src.resize((round(src.width * sh / src.height), sh), Image.LANCZOS)

    def frame(self, p):
        sw, sh = self.src.size
        x = lerp(0, sw - W, p)
        y = (sh - H) / 2
        return self.src.resize((W, H), Image.BICUBIC, box=(x, y, x + W, y + H))


class BlurBG:
    """写真をぼかし、暗く緑がかった色にして敷く"""

    def __init__(self, name, darken=0.45, tint=0.22):
        src = Image.open(IMG / name).convert("RGB")
        sw, sh = int(W * 1.12), int(H * 1.12)
        s = max(sw / src.width, sh / src.height)
        src = src.resize((round(src.width * s), round(src.height * s)), Image.LANCZOS)
        src = src.filter(ImageFilter.GaussianBlur(30))
        arr = np.asarray(src).astype(np.float32) * darken
        arr = arr * (1 - tint) + np.array((10, 70, 40), np.float32) * tint
        self.src = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))

    def frame(self, p):
        sw, sh = self.src.size
        z = lerp(1.0, 1.06, p)
        bw = min(sw, W * 1.12 / z)
        bh = bw * H / W
        x = (sw - bw) / 2
        y = (sh - bh) / 2
        return self.src.resize((W, H), Image.BICUBIC, box=(x, y, x + bw, y + bh))


# ---------------------------------------------------------------- 要素

def with_alpha(img, p):
    if p >= 0.999:
        return img
    out = img.copy()
    out.putalpha(img.getchannel("A").point(lambda v: int(v * p)))
    return out


class El:
    """静止RGBAレイヤーの登場アニメーション"""

    def __init__(self, img, x, y, t_in, anim="up", dur=0.35):
        self.img, self.x, self.y, self.t_in, self.anim, self.dur = img, x, y, t_in, anim, dur
        # add_shadow した画像は余白ぶん左上にずらして置く
        self.m = img.info.get("shadow_margin", 0)

    def draw(self, canvas, t):
        if t < self.t_in:
            return
        p = ease_out((t - self.t_in) / self.dur)
        img = self.img
        x, y = self.x - self.m, self.y - self.m
        if self.anim == "up":
            y += (1 - p) * 40
            img = with_alpha(img, p)
        elif self.anim == "left":
            x -= (1 - p) * 90
            img = with_alpha(img, p)
        elif self.anim == "right":
            x += (1 - p) * 90
            img = with_alpha(img, p)
        elif self.anim == "fade":
            img = with_alpha(img, p)
        elif self.anim == "scale":
            s = 0.82 + 0.18 * p
            if s < 0.999:
                w, h = img.size
                nw, nh = max(1, round(w * s)), max(1, round(h * s))
                img = img.resize((nw, nh), Image.BICUBIC)
                x += (w - nw) / 2
                y += (h - nh) / 2
            img = with_alpha(img, p)
        elif self.anim == "wipe":
            w = round(img.width * p)
            if w <= 0:
                return
            img = img.crop((0, 0, w, img.height))
        canvas.paste(img, (round(x), round(y)), img)


class Typing(El):
    """背景の帯は一気に出し、文字は1字ずつ出す"""

    def __init__(self, text, fpath, size, x, y, t0, t1):
        self.text, self.f, self.t0, self.t1 = text, font(fpath, size), t0, t1
        l, t, r, b = self.f.getbbox(text)
        padx, pady = 56, 34
        w, h = r - l + padx * 2, size + pady * 2
        band = Image.new("RGBA", (w, h), (0, 0, 0, 0))
        ImageDraw.Draw(band).rounded_rectangle((0, 0, w - 1, h - 1), radius=12, fill=GREEN + (242,))
        self.band = add_shadow(band, blur=14, offset=(0, 8), opacity=90)
        self.txt = Image.new("RGBA", (w, h), (0, 0, 0, 0))
        ImageDraw.Draw(self.txt).text((padx - l, (h - (b - t)) / 2 - t), text, font=self.f, fill=WHITE)
        self.padx, self.l = padx, l
        super().__init__(self.band, x, y, t0 - 0.2, "fade", 0.25)

    def draw(self, canvas, t):
        if t < self.t_in:
            return
        super().draw(canvas, t)
        n = int(len(self.text) * clamp((t - self.t0) / (self.t1 - self.t0))) + (1 if t >= self.t0 else 0)
        n = min(n, len(self.text))
        if n <= 0:
            return
        cw = self.padx + round(self.f.getlength(self.text[:n]))
        part = self.txt.crop((0, 0, cw, self.txt.height))
        canvas.paste(part, (self.x, self.y), part)


class Card:
    """白フチ+影つきの写真カード。中身はゆっくりズームする"""

    def __init__(self, name, x, y, w, h, t_in, span, anim="up", z=(1.0, 1.08), focus=(0.5, 0.5), border=10):
        src = Image.open(IMG / name).convert("RGB")
        self.src, self.x, self.y, self.w, self.h = src, x, y, w, h
        self.t_in, self.span, self.anim, self.z, self.b = t_in, span, anim, z, border
        sw, sh = src.size
        ar = w / h
        self.cw, self.ch = (sh * ar, sh) if sw / sh > ar else (sw, sw / ar)
        self.cx = clamp(sw * focus[0], self.cw / 2, sw - self.cw / 2)
        self.cy = clamp(sh * focus[1], self.ch / 2, sh - self.ch / 2)
        frame = Image.new("RGBA", (w + border * 2, h + border * 2), WHITE + (255,))
        self.shadow = add_shadow(frame, blur=20, offset=(0, 14), opacity=110)
        self.m = self.shadow.info["shadow_margin"]

    def draw(self, canvas, t):
        if t < self.t_in:
            return
        p = ease_out((t - self.t_in) / 0.4)
        zp = clamp((t - self.span[0]) / (self.span[1] - self.span[0]))
        zoom = lerp(self.z[0], self.z[1], zp)
        bw, bh = self.cw / zoom, self.ch / zoom
        box = (self.cx - bw / 2, self.cy - bh / 2, self.cx + bw / 2, self.cy + bh / 2)
        photo = self.src.resize((self.w, self.h), Image.BICUBIC, box=box)
        x, y = self.x, self.y
        if self.anim == "up":
            y += (1 - p) * 60
        elif self.anim == "left":
            x -= (1 - p) * 120
        elif self.anim == "right":
            x += (1 - p) * 120
        x, y = round(x), round(y)
        sh = with_alpha(self.shadow, p)
        canvas.paste(sh, (x - self.m, y - self.m), sh)
        framed = Image.new("RGB", (self.w + self.b * 2, self.h + self.b * 2), WHITE)
        framed.paste(photo, (self.b, self.b))
        mask = Image.new("L", framed.size, round(255 * p))
        canvas.paste(framed, (x, y), mask)


# ---------------------------------------------------------------- シーン

class Scene:
    def __init__(self, t0, t1, bg, elements, bug=True):
        self.t0, self.t1, self.bg, self.els, self.bug = t0, t1, bg, elements, bug

    def render(self, t):
        p = clamp((t - self.t0) / (self.t1 - self.t0 + TRANS))
        canvas = self.bg.frame(p).copy()
        for el in self.els:
            el.draw(canvas, t)
        if self.bug:
            canvas.paste(BUG, (W - MX - BUG.width + BUG.info["shadow_margin"], MY - BUG.info["shadow_margin"]), BUG)
        return canvas


def build_scenes():
    c = [0.0] + CUTS + [TOTAL]
    sc = []

    # 1. 静岡県〇〇市。精密切削加工のA社です。
    s0, s1 = c[0], c[1]
    logo = keyed_logo(560)
    sc.append(Scene(s0, s1, BrandBG(), [
        Card("access.jpg", MX, 170, 1120, 730, s0 + 0.05, (s0, s1 + TRANS), "left", z=(1.0, 1.1), focus=(0.55, 0.5)),
        El(chip("静岡県〇〇市", F_W6, 42, WHITE, GREEN), 1290, 330, T(0.05), "left"),
        El(text_img("精密切削加工の", F_W6, 66, DARK), 1290, 440, T(1.85), "up"),
        El(logo, 1285, 545, T(3.35), "scale", 0.4),
    ], bug=False))

    # 2. 光学機器や医療機器の、新しい製品づくりを支えています。
    s0, s1 = c[1], c[2]
    cw, ch = 788, 536
    x0 = (W - (cw * 2 + 70)) // 2
    sc.append(Scene(s0, s1, BrandBG(flip=True), [
        Card("product-03.jpg", x0, 170, cw, ch, T(5.25), (s0, s1 + TRANS), "up"),
        El(chip("光学機器", F_W6, 40, WHITE, GREEN), x0 + 24, 170 + ch - 50, T(5.44), "left"),
        Card("product-01.jpg", x0 + cw + 70, 170, cw, ch, T(6.10), (s0, s1 + TRANS), "up"),
        El(chip("医療機器", F_W6, 40, WHITE, GREEN), x0 + cw + 70 + 24, 170 + ch - 50, T(6.28), "left"),
        El(telop("光学機器・医療機器業界をサポート"), x0, 860, T(7.30), "wipe", 0.4),
    ]))

    # 3. 扱うのは、アルミや銅、ステンレスなどの非鉄金属。
    s0, s1 = c[2], c[3]
    cw, ch, gap = 540, 367, 50
    x0 = (W - (cw * 3 + gap * 2)) // 2
    y0 = 200
    mats = [
        ("product-04.jpg", "アルミ", "A5052", 11.10),
        ("product-05.jpg", "銅", "C1020", 12.10),
        ("product-06.jpg", "ステンレス", "SUS316L", 12.62),
    ]
    els = []
    for i, (name, label, grade, ta) in enumerate(mats):
        x = x0 + i * (cw + gap)
        els.append(Card(name, x, y0, cw, ch, T(ta), (s0, s1 + TRANS), "up", z=(1.0, 1.06)))
        els.append(El(text_img(label, F_W8, 64, DARK), x + 4, y0 + ch + 44, T(ta + 0.05), "up", 0.25))
        els.append(El(text_img(grade, F_W3, 34, GRAY), x + 8, y0 + ch + 134, T(ta + 0.12), "up", 0.25))
    els.append(El(telop("非鉄金属の精密切削加工"), x0, 880, T(13.55), "wipe", 0.35))
    sc.append(Scene(s0, s1, BrandBG(), els))

    # 4. 厚さ0.3ミリのフィンも、一個から削り出します。
    s0, s1 = c[3], c[4]
    big = text_img("0.3", F_W8, 230, GREEN)
    unit = text_img("mm", F_W6, 96, GREEN)
    num = Image.new("RGBA", (big.width + unit.width + 10, big.height), (0, 0, 0, 0))
    num.alpha_composite(big, (0, 0))
    num.alpha_composite(unit, (big.width + 6, big.height - unit.height - 8))
    sc.append(Scene(s0, s1, BrandBG(flip=True), [
        Card("product-02.jpg", MX, 196, 1012, 688, s0 + 0.05, (s0, s1 + TRANS), "left", z=(1.0, 1.14), focus=(0.5, 0.45)),
        El(chip("ヒートシンク|A1050", F_W6, 32, WHITE, GREEN), MX + 28, 196 + 688 - 44, T(15.40), "left"),
        El(text_img("フィン厚", F_W6, 54, DARK), 1210, 250, T(15.35), "up"),
        El(num, 1200, 330, T(15.72), "scale", 0.3),
        El(chip("1個から対応", F_W6, 60, WHITE, GREEN, padx=40, pady=22), 1210, 660, T(17.10), "left"),
    ]))

    # 5. 開発や試作の段階から、最適な加工方法を提案します。
    s0, s1 = c[4], c[5]
    sc.append(Scene(s0, s1, BlurBG("elected-2.jpg"), [
        Card("elected-2.jpg", MX, 150, 1060, 685, s0 + 0.05, (s0, s1 + TRANS), "left", z=(1.0, 1.08), focus=(0.5, 0.4)),
        Card("technology-1.jpg", W - MX - 640, 400, 640, 421, T(20.15), (s0, s1 + TRANS), "right", z=(1.0, 1.06)),
        El(chip("3Dプリンターで試作", F_W6, 34, WHITE, GREEN), W - MX - 640 + 24, 400 + 421 - 44, T(20.35), "left"),
        El(telop("試作・開発段階から、最適な加工方法をご提案"), MX, 880, T(21.20), "wipe", 0.45),
    ]))

    # 6. 三次元測定機とマイクロスコープで、品質を一つずつ確かめます。
    s0, s1 = c[5], c[6]
    cw, ch = 840, 560
    xr = W - MX - cw
    sc.append(Scene(s0, s1, BlurBG("quality-2.jpg"), [
        Card("quality-2.jpg", MX, 160, cw, ch, s0 + 0.05, (s0, s1 + TRANS), "up", z=(1.0, 1.08), focus=(0.5, 0.45)),
        El(chip("三次元測定機", F_W6, 40, WHITE, GREEN), MX + 24, 160 + ch - 50, T(24.40), "left"),
        El(text_img("幾何公差を保証", F_W6, 36, WHITE, shadow=True), MX + 6, 160 + ch + 40, T(24.60), "up"),
        Card("facility-08.jpg", xr, 160, cw, ch, T(25.50), (s0, s1 + TRANS), "up", z=(1.0, 1.06)),
        El(chip("マイクロスコープ", F_W6, 40, WHITE, GREEN), xr + 24, 160 + ch - 50, T(25.60), "left"),
        El(text_img("ミクロン単位でバリを確認", F_W6, 36, WHITE, shadow=True), xr + 6, 160 + ch + 40, T(25.80), "up"),
        El(telop("品質を、ひとつずつ確かめる"), MX, 880, T(26.92), "wipe", 0.4),
    ]))

    # 7. 新工場で、30代を中心としたチームが挑み続けています。
    s0, s1 = c[6], c[7]
    cw, ch, gap = 548, 440, 42
    x0 = (W - (cw * 3 + gap * 2)) // 2
    sc.append(Scene(s0, s1, BlurBG("elected-4.jpg"), [
        El(chip("新工場「〇〇」", F_W6, 42, GREEN, WHITE + (245,), accent=LIME), x0, 170, T(30.05), "left"),
        Card("elected-4.jpg", x0, 290, cw, ch, T(30.00), (s0, s1 + TRANS), "up", focus=(0.75, 0.5)),
        Card("recruit-6.jpg", x0 + cw + gap, 290, cw, ch, T(30.35), (s0, s1 + TRANS), "up"),
        Card("facility-01.jpg", x0 + (cw + gap) * 2, 290, cw, ch, T(30.70), (s0, s1 + TRANS), "up"),
        El(text_img("30代中心のチーム", F_W8, 100, WHITE, shadow=True), x0 - 14, 790, T(30.86), "up", 0.35),
    ]))

    # 8. (経営理念)。
    s0, s1 = c[7], c[8]
    slogan = Typing("(経営理念)", F_W8, 92, 0, 0, T(34.84), T(36.70))
    slogan.x = (W - slogan.band.width + slogan.m * 2) // 2
    slogan.y = 820
    sc.append(Scene(s0, s1, BlurBG("recruit-9.jpg"), [
        Card("recruit-9.jpg", (W - 1080) // 2, 60, 1080, 711, s0 + 0.05, (s0, s1 + TRANS), "up", z=(1.0, 1.08)),
        slogan,
    ]))

    # エンドカード:A社。
    s0, s1 = c[8], c[9]
    end_logo = keyed_logo(900)
    lx = (W - end_logo.width) // 2
    info = text_img("www.example.co.jp | TEL 000-000-0000", F_W6, 44, DARK)
    addr = text_img("〒000-0000 静岡県〇〇市〇〇", F_W3, 32, GRAY)
    sl = text_img("(経営理念)", F_W6, 44, GREEN)
    sc.append(Scene(s0, s1, BrandBG(), [
        El(sl, (W - sl.width) // 2, 250, T(37.35), "up"),
        El(end_logo, lx, 340, T(37.50), "scale", 0.45),
        El(info, (W - info.width) // 2, 650, T(38.30), "up"),
        El(addr, (W - addr.width) // 2, 730, T(38.45), "up"),
    ], bug=False))
    return sc


# ---------------------------------------------------------------- 合成

YY, XX = np.mgrid[0:H, 0:W].astype(np.float32)


def wipe(a, b, p):
    """左から右へ流れる斜めの緑帯で a→b に切り替える"""
    e = lerp(-500, W + 500, ease_in_out(p)) - (YY - H / 2) * 0.35
    A = np.asarray(a)
    B = np.asarray(b)
    out = np.where((XX < e)[..., None], B, A).copy()
    out[(XX >= e) & (XX < e + 90)] = GREEN
    out[(XX >= e + 90) & (XX < e + 118)] = LIME
    return Image.fromarray(out)


def render_frame(scenes, t):
    for i, cut in enumerate(CUTS):
        if abs(t - cut) < TRANS / 2:
            p = (t - (cut - TRANS / 2)) / TRANS
            return wipe(scenes[i].render(t), scenes[i + 1].render(t), p)
    idx = sum(1 for cut in CUTS if t >= cut)
    img = scenes[idx].render(t)
    if t < 0.3:
        img = Image.blend(Image.new("RGB", (W, H), WHITE), img, t / 0.3)
    return img


def main():
    global BUG
    BUG = logo_bug()
    scenes = build_scenes()
    mode = sys.argv[1]
    if mode == "preview":
        for s in sys.argv[2:]:
            render_frame(scenes, float(s)).save(BASE / f"preview_{s}.png")
        return
    out = sys.argv[2]
    n = round(TOTAL * FPS)
    delay = int(LEAD * 1000)
    cmd = [
        "ffmpeg", "-y", "-loglevel", "error",
        "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-",
        "-i", str(AUDIO),
        "-filter:a", f"adelay={delay}:all=1,apad",
        "-t", f"{TOTAL}",
        "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium",
        "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
        "-movflags", "+faststart", out,
    ]
    proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
    for i in range(n):
        frame = render_frame(scenes, i / FPS)
        proc.stdin.write(frame.tobytes())
        if i % 150 == 0:
            print(f"{i}/{n}", flush=True)
    proc.stdin.close()
    proc.wait()
    print("done", proc.returncode)


if __name__ == "__main__":
    main()

D. BGMの合成

bgm.py(全文)
bgm.py
#!/usr/bin/env python3
"""A社PV用のBGMを合成する(オリジナル曲・外部音源なし)

120BPM、4/4、1小節 = 2秒。D - A - Bm - G の進行を繰り返し、
エンドカード(動画 38 秒付近)で D に解決してフェードアウトする。
"""
import wave
from pathlib import Path

import numpy as np

SR = 48000
TOTAL = 41.6
BPM = 120
BEAT = 60 / BPM
BAR = BEAT * 4
OUT = Path(__file__).resolve().parent / "bgm.wav"

rng = np.random.default_rng(7)
N = int(TOTAL * SR)
L = np.zeros(N)
R = np.zeros(N)


def midi_hz(m):
    return 440.0 * 2 ** ((m - 69) / 12)


def add(sig, t0, gain=1.0, pan=0.0):
    """pan: -1 = 左, +1 = 右"""
    i0 = int(t0 * SR)
    if i0 >= N:
        return
    sig = sig[: N - i0]
    gl = gain * np.cos((pan + 1) * np.pi / 4)
    gr = gain * np.sin((pan + 1) * np.pi / 4)
    L[i0:i0 + len(sig)] += sig * gl
    R[i0:i0 + len(sig)] += sig * gr


def env_adsr(n, a, d, s, r):
    """a/d/r は秒、s はサステインレベル。n サンプルの長さに合わせる"""
    t = np.arange(n) / SR
    dur = n / SR
    e = np.where(t < a, t / a, s + (1 - s) * np.exp(-(t - a) / max(d, 1e-3)))
    rel = np.clip((dur - t) / r, 0, 1)
    return e * rel


def pluck(m, dur=1.2):
    """エレピ風:倍音ごとに減衰の速さを変える"""
    n = int(dur * SR)
    t = np.arange(n) / SR
    f = midi_hz(m)
    sig = np.zeros(n)
    for k, amp in ((1, 1.0), (2, 0.45), (3, 0.22), (4, 0.12), (6, 0.05)):
        sig += amp * np.sin(2 * np.pi * f * k * t) * np.exp(-t * (2.2 + 1.8 * k))
    attack = np.clip(t / 0.004, 0, 1)
    return sig * attack


def pad(notes, dur):
    """ゆっくり立ち上がる柔らかいパッド(デチューンした倍音合成)"""
    n = int(dur * SR)
    t = np.arange(n) / SR
    sig = np.zeros(n)
    for m in notes:
        f = midi_hz(m)
        for det in (-0.12, 0.12):
            ff = f * 2 ** (det / 12)
            for k in range(1, 7):
                sig += np.sin(2 * np.pi * ff * k * t + rng.uniform(0, 6.28)) / k ** 2.0
    trem = 1 + 0.06 * np.sin(2 * np.pi * 0.5 * t)
    return sig * trem * env_adsr(n, 0.45, 1.0, 0.85, 0.5) / (len(notes) * 4)


def bass(m, dur):
    n = int(dur * SR)
    t = np.arange(n) / SR
    f = midi_hz(m)
    sig = np.sin(2 * np.pi * f * t) + 0.3 * np.sin(2 * np.pi * 2 * f * t)
    return sig * env_adsr(n, 0.008, 0.18, 0.45, 0.05)


def kick():
    n = int(0.35 * SR)
    t = np.arange(n) / SR
    f = 45 + 85 * np.exp(-t * 28)
    ph = 2 * np.pi * np.cumsum(f) / SR
    return np.sin(ph) * np.exp(-t * 9)


def band_noise(n, lo, hi):
    """帯域を絞ったノイズ(FFT 上で対数周波数のなだらかな窓をかける)"""
    x = np.fft.rfft(rng.standard_normal(n))
    f = np.fft.rfftfreq(n, 1 / SR)
    lf = np.log2(np.maximum(f, 1))
    c, bw = (np.log2(lo) + np.log2(hi)) / 2, (np.log2(hi) - np.log2(lo)) / 2
    x *= np.exp(-((lf - c) / bw) ** 4)
    y = np.fft.irfft(x, n)
    return y / (np.std(y) + 1e-9)


def hat(open_=False):
    n = int((0.18 if open_ else 0.06) * SR)
    t = np.arange(n) / SR
    x = band_noise(n, 5000, 10000)
    return x * np.exp(-t * (18 if open_ else 70)) * 0.07


def clap():
    n = int(0.25 * SR)
    t = np.arange(n) / SR
    x = band_noise(n, 900, 3500) * 0.3
    bursts = sum(np.exp(-np.clip(t - d, 0, None) * 180) * (t >= d) for d in (0, 0.011, 0.022))
    return x * (bursts * 0.5 + np.exp(-t * 16)) * 0.35


def swell(dur):
    """終止前のノイズの吸い上げ"""
    n = int(dur * SR)
    t = np.arange(n) / SR
    x = band_noise(n, 1500, 7000)
    return x * (t / dur) ** 2.5 * 0.04


def crash():
    n = int(2.5 * SR)
    t = np.arange(n) / SR
    x = band_noise(n, 3000, 9000)
    return x * np.exp(-t * 2.2) * 0.05


# D - A - Bm - G(D メジャー)
CHORDS = [
    {"pad": [62, 66, 69], "arp": [74, 78, 81, 86], "bass": 38},   # D
    {"pad": [61, 64, 69], "arp": [73, 76, 81, 85], "bass": 45},   # A
    {"pad": [62, 66, 71], "arp": [74, 78, 83, 86], "bass": 47},   # Bm
    {"pad": [62, 67, 71], "arp": [74, 79, 83, 86], "bass": 43},   # G
]
ARP_ORDER = [0, 1, 2, 3, 2, 1, 2, 3]
N_BARS = 19          # 0〜38 秒。最終小節(38 秒〜)は D の全音符で締める

for b in range(N_BARS):
    t0 = b * BAR
    ch = CHORDS[b % 4]
    add(pad(ch["pad"], BAR + 0.5), t0, 0.55)

    # アルペジオ:イントロ2小節は4分、以降は8分
    step = BEAT if b < 2 else BEAT / 2
    for i in range(int(BAR / step)):
        m = ch["arp"][ARP_ORDER[(i * (2 if b < 2 else 1)) % 8]]
        vel = 0.9 if i % 2 == 0 else 0.65
        add(pluck(m), t0 + i * step, 0.16 * vel, pan=-0.35 if i % 2 else 0.35)

    if b >= 2:
        for i in range(8):
            add(bass(ch["bass"], BEAT / 2 * 0.95), t0 + i * BEAT / 2, 0.20 * (1.0 if i % 2 == 0 else 0.7))
        for i in range(4):
            if b < N_BARS - 1 or i < 2:
                add(kick(), t0 + i * BEAT, 0.38 if i in (0, 2) else 0.24)
        for i in range(8):
            add(hat(open_=(i % 2 == 1 and b % 2 == 1)), t0 + i * BEAT / 2, 0.8 if i % 2 else 0.45, pan=0.25)
        if b >= 4:
            add(clap(), t0 + BEAT, 0.5, pan=-0.1)
            add(clap(), t0 + BEAT * 3, 0.5, pan=-0.1)

# 最終小節前のフィル → 38 秒で D に解決
add(swell(BEAT * 2), (N_BARS - 1) * BAR + BEAT * 2, 1.0)
t_end = N_BARS * BAR
add(pad([62, 66, 69, 74], TOTAL - t_end), t_end, 0.7)
add(bass(38, 2.0), t_end, 0.35)
add(kick(), t_end, 0.6)
add(crash(), t_end, 1.0)
for i, m in enumerate([74, 78, 81, 86]):
    add(pluck(m, 2.5), t_end + i * 0.06, 0.16)

# 簡易リバーブ(減衰ノイズとの畳み込み)
ir_n = int(1.4 * SR)
ir_t = np.arange(ir_n) / SR
for ch_sig in (L, R):
    ir = rng.standard_normal(ir_n) * np.exp(-ir_t * 4.5)
    ir[0] = 0
    wet = np.fft.irfft(np.fft.rfft(ch_sig, 2 ** 22) * np.fft.rfft(ir, 2 ** 22))[:N]
    ch_sig += wet * (0.25 / np.sqrt(np.sum(ir ** 2)))

# 頭のクリック防止と、最後の 1.8 秒のフェードアウト
fade_in = np.clip(np.arange(N) / (0.02 * SR), 0, 1)
fade_out = np.clip((TOTAL - np.arange(N) / SR) / 1.8, 0, 1) ** 1.5
mix = np.stack([L, R], axis=1) * (fade_in * fade_out)[:, None]
mix /= np.max(np.abs(mix)) / 0.89       # ピーク -1 dBFS
pcm = (mix * 32767).astype(np.int16)

with wave.open(str(OUT), "wb") as w:
    w.setnchannels(2)
    w.setsampwidth(2)
    w.setframerate(SR)
    w.writeframes(pcm.tobytes())
print("wrote", OUT, f"{N / SR:.2f}s")

E. ナレーションとBGMの合成

1本目で、ナレーションを0.4秒遅らせてステレオにし、BGMをダッキングして混ぜます。
2本目で、全体を-16 LUFS前後に持ち上げ、映像を再エンコードせずに音声だけ差し替えます。

ffmpegのコマンド
ffmpeg -y -i narration.wav -i bgm.wav -filter_complex "[0:a]aresample=48000,pan=stereo|c0=c0|c1=c0,adelay=400:all=1,apad,atrim=0:41.6,asplit=2[nar][sc];[1:a]volume=-16dB[b];[b][sc]sidechaincompress=threshold=0.03:ratio=6:attack=30:release=450[bd];[nar][bd]amix=inputs=2:duration=shortest:normalize=0" -t 41.6 mix_raw.wav

ffmpeg -y -i promo.mp4 -i mix_raw.wav -map 0:v -map 1:a -c:v copy -af "volume=3.9dB,alimiter=limit=0.84:level=false" -c:a aac -b:a 192k -ar 48000 -t 41.6 -movflags +faststart promo_bgm.mp4

volume=3.9dB は、1本目の出力を測った値(-19.9 LUFS)から-16 LUFSまでの差です。
音声が変われば測り直してください。

ffmpeg -hide_banner -i mix_raw.wav -af ebur128=peak=true -f null - 2>&1 | grep -A14 Summary

F. gemini-tts スキル

~/.claude/skills/gemini-tts/SKILL.md と ~/.claude/skills/gemini-tts/scripts/tts.py の2ファイルで動きます。
SKILL.md は、手元で使っているものをそのまま載せます。キーが無料枠か有料枠かは、使う前にClaudeが確認する書き方にしてあります。

SKILL.md(全文)
SKILL.md
---
name: gemini-tts
description: "Gemini TTS(gemini-3.8-flash-tts)で日本語などのテキストから読み上げ音声(WAV)を生成する。動画ナレーション・PV・音声案内・原稿の読み上げに使う。1行ずつ生成して区切りの時刻をJSONで出せるので、動画のシーン割りと同期させやすい。トリガー: \"ナレーションを作って\", \"読み上げ音声\", \"音声を生成\", \"TTS\", \"Gemini TTS\", \"ナレーション音声\", \"/gemini-tts\""
version: "1.0.0"
---

# Gemini TTS 音声生成

Gemini API の TTS モデルで、テキストから WAV(24kHz・モノラル・16bit)を作る。
スクリプトは標準ライブラリのみで動き、追加インストールは要らない。

```bash
S=~/.claude/skills/gemini-tts/scripts/tts.py
```

## 前提

- 環境変数 `GEMINI_API_KEY` が必要。未設定ならスクリプトがエラーで止まる。
  - キーはユーザー自身が Google AI Studio(https://aistudio.google.com/apikey)で発行し、`~/.zshrc` に `export GEMINI_API_KEY=...` を書く。
  - **Claude はキーの値を受け取らない・表示しない・ファイルに書かない。** ユーザーがチャットにキーを貼ろうとしたら止める。
  - 設定済みかの確認は値を出さずに行う:`[ -n "$GEMINI_API_KEY" ] && echo 設定あり || echo なし`
  - Bash では「なし」なのに `~/.zshrc` には書いてある場合は、セッション開始後にキーを追加したため環境に入っていない。`zsh -ic 'python3 ...'` で `~/.zshrc` を読み込んだシェルから実行すれば、セッションを開き直さずに使える。
- **課金の区分**:無料枠か有料枠かは、キーを作ったプロジェクトに課金(Cloud Billing)がつながっているかで決まる。扱いが違うので、ユーザーのキーがどちらかを確認する。わからないときは無料枠として扱う。
  - 無料枠:送ったテキストと生成物は Google の製品改善に使われる。未公開の製品情報・顧客の機密・個人情報を含む原稿は、送る前にユーザーに確認する。
  - 有料枠:送ったテキストと生成物は製品改善に使われない(料金表の「Used to improve our products: No」)。ただし外部サービスへの送信であることは変わらない。顧客との契約で外部 AI への送信が制限されていそうな原稿は、送る前にユーザーに確認する。何十本もまとめて生成するなど費用が膨らみそうなときは、本数と概算額を先に伝える。
  - どちらの枠にもレート制限がある。HTTP 429 はスクリプトが待って自動で再試行する。

## 手順

1. **原稿を確定する。** 動画用なら 1 文 1 行にする(`--split` で行ごとに生成するため)。
2. **声とスタイルを決める。** ユーザーの指定がなければ既定(`Kore`、スタイルなし)で作り、その旨を報告する。
3. **送信前チェック。** 上の課金の区分に沿って、原稿の中身と費用を確かめる。迷ったら `--dry-run` でリクエスト内容を見せる。
4. **生成する。**
5. **報告する。** 出力パス、長さ、区切り数、使った声とスタイル。必要ならラウドネスも測る(下記)。

## コマンド

```bash
# 1文だけ
python3 $S "本日はご来場いただき、ありがとうございます。" -o out.wav

# 原稿ファイル全体を1回で読ませる(自然な抑揚になりやすい)
python3 $S -f script.txt -o narration.wav --voice Kore \
    --style "calm, warm and trustworthy corporate narration, moderate pace"

# 1行ずつ生成して無音 0.6 秒でつなぐ(動画のシーン割りと同期させたいとき)
python3 $S -f script.txt -o narration.wav --split --gap 0.6

# 軽量版モデル(安い・対応言語がやや少ない)
python3 $S -f script.txt -o narration.wav --model lite

# APIを呼ばずにリクエストだけ確認
python3 $S -f script.txt -o narration.wav --split --dry-run
```

出力:
- `narration.wav`
- `narration.json`:モデル、声、スタイル、総尺と、各行の `start` / `end`(秒)。`--split` のときは各行の区切り時刻をそのまま動画のシーン切り替えに使える。`--split` なしでは全体が 1 区切りになる。

### 1回生成と `--split` の使い分け

| | 1回生成 | `--split` |
|---|---|---|
| 抑揚 | 文をまたいで自然につながる | 行ごとに独立するので、文頭の調子が毎回リセットされやすい |
| 区切り時刻 | 分からない(無音検出や文字起こしで後から求める) | JSON に正確に出る |
| 間の長さ | モデル任せ | `--gap` で一定にできる |
| 作り直し | 全体をやり直す | 同じ行を作り直せば済む(現状は全行を再生成する) |

## 声(30種・公式の特徴)

Zephyr(Bright)、Puck(Upbeat)、Charon(Informative)、Kore(Firm)、Fenrir(Excitable)、Leda(Youthful)、Orus(Firm)、Aoede(Breezy)、Callirrhoe(Easy-going)、Autonoe(Bright)、Enceladus(Breathy)、Iapetus(Clear)、Umbriel(Easy-going)、Algieba(Smooth)、Despina(Smooth)、Erinome(Clear)、Algenib(Gravelly)、Rasalgethi(Informative)、Laomedeia(Upbeat)、Achernar(Soft)、Alnilam(Firm)、Schedar(Even)、Gacrux(Mature)、Pulcherrima(Forward)、Achird(Friendly)、Zubenelgenubi(Casual)、Vindemiatrix(Gentle)、Sadachbia(Lively)、Sadaltager(Knowledgeable)、Sulafat(Warm)

どれが男声・女声かは公式の一覧に書かれていない。聞き比べが必要なら、同じ 1 文を候補の声で生成して並べる。

## スタイルと記法

- `--style`:発話全体に効く指示(感情、話し方、テンポ、声量)。公式例は英語。日本語で書いても効くかは未検証なので、確実さを取るなら英語で書く。
- 原稿中のタグ:一瞬の声の演出は本文に英語タグで入れる。原稿が日本語でもタグは英語が推奨。
  - 間:`<short pause>` `<long pause>`
  - その他:`<breath>` `<sigh>` `<laugh>` `<chuckle>` `<whispers>` `<throat-clearing>` など
- 言語は本文から自動判定される。

## 生成後の確認(任意)

```bash
# 長さ
ffprobe -v error -show_entries format=duration -of csv=p=0 narration.wav
# ラウドネス(Web 動画に使うなら全体で -16 LUFS 前後が目安)
ffmpeg -hide_banner -i narration.wav -af ebur128=peak=true -f null - 2>&1 | grep -A12 Summary
```

## 料金(参考・2026-09 時点の公式料金表)

- 無料枠:入出力とも無料(データは製品改善に使われる)。
- 有料枠:gemini-3.8-flash-tts は入力 0.50ドル / 出力(音声)9.00ドル(100万トークンあたり。2027-01-01 から 1.00ドル / 18.00ドル)。データは製品改善に使われない。
- 目安:音声 1 秒 ≒ 32 トークン(音声理解の公式換算値。TTS 出力にもそのまま当てはまるかは未確認)で、40 秒のナレーションが約 0.012ドル。

## 出典

- https://ai.google.dev/gemini-api/docs/speech-generation
- https://ai.google.dev/gemini-api/docs/pricing
- https://ai.google.dev/gemini-api/docs/google-ai-plans
scripts/tts.py(全文)
tts.py
#!/usr/bin/env python3
"""Gemini TTS(gemini-3.8-flash-tts)で読み上げ音声を生成する

標準ライブラリのみで動く。APIキーは環境変数 GEMINI_API_KEY から読む。

使い方:
    tts.py "読み上げる文" -o out.wav
    tts.py -f script.txt -o out.wav --voice Kore --style "calm, warm corporate narration"
    tts.py -f script.txt -o out.wav --split --gap 0.6    # 1行ずつ生成してつなぐ
    tts.py -f script.txt -o out.wav --dry-run           # APIを呼ばずにリクエストを表示
"""
import argparse
import base64
import io
import json
import os
import sys
import time
import urllib.error
import urllib.request
import wave
from pathlib import Path

ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/interactions"
MODELS = {
    "flash": "gemini-3.8-flash-tts",
    "lite": "gemini-3.8-flash-lite-tts",
}
DEFAULT_VOICE = "Kore"
VOICES = {
    "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", "Callirrhoe",
    "Autonoe", "Enceladus", "Iapetus", "Umbriel", "Algieba", "Despina", "Erinome", "Algenib",
    "Rasalgethi", "Laomedeia", "Achernar", "Alnilam", "Schedar", "Gacrux", "Pulcherrima",
    "Achird", "Zubenelgenubi", "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat",
}
# ヘッダーなしPCMが返ったときの形式(仕様:24kHz・モノラル・16bit)
RAW_PARAMS = (24000, 1, 2)
RETRY_STATUS = {429, 500, 502, 503, 504}


def build_request(text, voice, style, model):
    content = {"type": "text", "text": text}
    if style:
        content["annotations"] = [{"type": "speech_metadata", "style": style}]
    return {
        "model": model,
        "input": [{"type": "user_input", "content": [content]}],
        "response_format": {"type": "audio"},
        "generation_config": {"speech_config": [{"voice": voice}]},
    }


def call_api(body, api_key, retries=4):
    data = json.dumps(body).encode("utf-8")
    for attempt in range(retries + 1):
        req = urllib.request.Request(
            ENDPOINT,
            data=data,
            headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=180) as res:
                return json.loads(res.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            detail = e.read().decode("utf-8", errors="replace")[:800]
            if e.code in RETRY_STATUS and attempt < retries:
                wait = float(e.headers.get("Retry-After") or 0) or 2 ** (attempt + 1)
                print(f"  HTTP {e.code}、{wait:.0f}秒待って再試行します({attempt + 1}/{retries})", file=sys.stderr)
                time.sleep(wait)
                continue
            raise RuntimeError(f"Gemini API エラー HTTP {e.code}: {detail}") from None
        except urllib.error.URLError as e:
            if attempt < retries:
                time.sleep(2 ** (attempt + 1))
                continue
            raise RuntimeError(f"接続エラー: {e.reason}") from None


def extract_audio(resp):
    """レスポンスの最後の音声ブロックを取り出し、(PCM, (rate, ch, width)) を返す"""
    audios = [
        c
        for step in resp.get("steps", [])
        if step.get("type") == "model_output"
        for c in step.get("content", [])
        if c.get("type") == "audio"
    ]
    if not audios:
        raise RuntimeError("レスポンスに音声が含まれていません: " + json.dumps(resp, ensure_ascii=False)[:800])
    raw = base64.b64decode(audios[-1]["data"])
    if raw[:4] == b"RIFF":
        with wave.open(io.BytesIO(raw)) as w:
            return w.readframes(w.getnframes()), (w.getframerate(), w.getnchannels(), w.getsampwidth())
    return raw, RAW_PARAMS


def silence(seconds, params):
    rate, ch, width = params
    return b"\x00" * (int(seconds * rate) * ch * width)


def write_wav(path, pcm, params):
    rate, ch, width = params
    with wave.open(str(path), "wb") as w:
        w.setnchannels(ch)
        w.setsampwidth(width)
        w.setframerate(rate)
        w.writeframes(pcm)


def seconds_of(pcm, params):
    rate, ch, width = params
    return len(pcm) / (rate * ch * width)


def main():
    ap = argparse.ArgumentParser(description="Gemini TTS で読み上げ音声(WAV)を生成する")
    src = ap.add_mutually_exclusive_group(required=True)
    src.add_argument("text", nargs="?", help="読み上げるテキスト")
    src.add_argument("-f", "--file", help="原稿ファイル(UTF-8)")
    ap.add_argument("-o", "--out", required=True, help="出力WAVのパス")
    ap.add_argument("--voice", default=DEFAULT_VOICE, help=f"声の名前(既定: {DEFAULT_VOICE})")
    ap.add_argument("--style", help="読み方の指示(例: \"calm, warm corporate narration, moderate pace\")")
    ap.add_argument("--model", default="flash", help="flash / lite / またはモデルIDをそのまま指定")
    ap.add_argument("--split", action="store_true", help="空行以外の1行を1区切りとして個別に生成し、つなぐ")
    ap.add_argument("--gap", type=float, default=0.5, help="--split 時の区切り間の無音(秒、既定 0.5)")
    ap.add_argument("--lead", type=float, default=0.0, help="先頭に入れる無音(秒)")
    ap.add_argument("--dry-run", action="store_true", help="APIを呼ばずにリクエスト内容だけ表示する")
    args = ap.parse_args()

    text = Path(args.file).read_text(encoding="utf-8") if args.file else args.text
    text = text.strip()
    if not text:
        sys.exit("エラー: テキストが空です")
    if args.voice not in VOICES:
        print(f"注意: '{args.voice}' は既知の30音声にありません。そのまま送信します", file=sys.stderr)
    model = MODELS.get(args.model, args.model)
    segments = [line.strip() for line in text.splitlines() if line.strip()] if args.split else [text]
    bodies = [build_request(s, args.voice, args.style, model) for s in segments]

    if args.dry_run:
        print(json.dumps(bodies if args.split else bodies[0], ensure_ascii=False, indent=4))
        return

    api_key = os.environ.get("GEMINI_API_KEY")
    if not api_key:
        sys.exit(
            "エラー: 環境変数 GEMINI_API_KEY が設定されていません。\n"
            "Google AI Studio(https://aistudio.google.com/apikey)でキーを発行し、\n"
            "~/.zshrc に export GEMINI_API_KEY=... を追記してください(キーはClaudeに貼らないこと)。"
        )

    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    params = None
    chunks, timeline = [], []
    cursor = args.lead
    for i, (seg, body) in enumerate(zip(segments, bodies), 1):
        print(f"[{i}/{len(segments)}] {seg[:40]}{'…' if len(seg) > 40 else ''}", file=sys.stderr)
        pcm, p = extract_audio(call_api(body, api_key))
        if params is None:
            params = p
            chunks.append(silence(args.lead, params))
        elif p != params:
            raise RuntimeError(f"区切りごとの音声形式が一致しません: {params} と {p}")
        if i > 1:
            chunks.append(silence(args.gap, params))
            cursor += args.gap
        dur = seconds_of(pcm, params)
        timeline.append({"index": i, "text": seg, "start": round(cursor, 3), "end": round(cursor + dur, 3)})
        cursor += dur
        chunks.append(pcm)

    pcm_all = b"".join(chunks)
    write_wav(out, pcm_all, params)
    meta = {
        "model": model,
        "voice": args.voice,
        "style": args.style,
        "sample_rate": params[0],
        "duration": round(seconds_of(pcm_all, params), 3),
        "segments": timeline,
    }
    meta_path = out.with_suffix(".json")
    meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=4), encoding="utf-8")
    print(f"出力: {out}({meta['duration']:.2f}秒、{len(segments)}区切り、{model} / {args.voice})")
    print(f"区切りの時刻: {meta_path}")


if __name__ == "__main__":
    main()
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?