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の仕様は次のとおりです(上の画像は、社名、所在地、人物をぼかしています)。
- 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キー(呼び出す人を識別するための鍵となる文字列。パスワードと同じく他人に見せない)を用意します。
- 付録Fの2ファイルを、
~/.claude/skills/gemini-tts/SKILL.mdと~/.claude/skills/gemini-tts/scripts/tts.pyに置く - Google AI Studio(GoogleのAIをブラウザで試したり、APIキーを発行したりできるサイト)のAPI Keysページで、APIキーを作る。制限を付けるなら「Restrict to Gemini API only」を選ぶ
-
~/.zshrcにexport GEMINI_API_KEY=...を書き、source ~/.zshrcで読み込む。~/.zshrcは、macOSのターミナル(zsh)が起動するたびに読む設定ファイルで、ここに書いた値が環境変数(プログラムが読み取れる設定値)になる - Claude Codeのセッション(1回分の会話)を開き直す。キーを登録する前に起動したセッションには、キーが入らない
- 設定を確かめる。キーの値は画面に出さない
[ -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に渡します。
書き出しは次の順で進めます。
- 静止画を書き出して確かめる(
python3 render.py preview 4.6 9.5 14.2 ...)。今回はこの段階で、4シーン目の「mm」が画面の右端で切れていることと、7シーン目の下半分が空きすぎていることが見つかった - 直してから全編を書き出す(
python3 render.py render promo.mp4)。今回は1,248フレームで、手元のMacで約45秒だった - 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(全文)
#!/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(全文)
#!/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(全文)
---
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(全文)
#!/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()
