
PDF10枚を渡したら、4分50秒の研修動画が返ってきた。──人の声ゼロ。Gemini 3.8 Flash TTS×Claude Codeの制作ラインを全部見せます
9月30日の夜11時前、自社の経営者向けミニ教材のPDF10枚をClaude Codeに渡しました。翌0時40分に返ってきたのは、4分50秒の研修動画です。ナレーションはGoogleのGemini 3.8 Flash TTSで、人の声は一秒も入っていません。台本と映像はClaude Opus 5.5が作りました。途中で何が起き、どこで基準を自分で動かしたのか。使った依頼文とコードを計20本(各10本)、そのまま使える形で載せます。

研修動画は、作った瞬間から古くなる
社内研修の資料は、多くの会社でPDFやスライドのまま眠っています。作った本人が説明すれば伝わるのに、本人が忙しくなると誰も開かなくなる。新しく入った人には「このPDF、読んでおいて」で終わってしまう。
動画にすればいい、とは誰でも思います。ただ、研修動画には作り直しの重さがつきまといます。ナレーションを誰かが読んで録り、映像と合わせ、字幕を付ける。そこまでやって完成しても、制度や手順が変われば、また録り直しです。一文を直すために収録をやり直す手間を考えると、担当者は「今回はPDFのままでいいか」と判断します。こうして、教材は紙のまま残っていきます。
もう一つの壁は、声の統一です。途中で担当者が替わったり、別の日に録り足したりすると、同じ動画の中で声の調子が変わってしまう。見る側は、案外そこに気づきます。録り直しを避けたくなる気持ちには、こうした事情もあります。

私が今回試したかったのは、この重さをどこまで軽くできるかでした。声を人が録らなくてよければ、直したい一文だけを作り直せます。映像が「スライドの原本+動き」で作れるなら、デザインを一から起こす必要もありません。PDFを渡すだけで、研修で使える水準の動画が返ってくるのか。自分の教材で確かめることにしました。
素材に選んだのは、自社で作った経営者向けのミニ教材「会社の記憶をAIに接続する」です。Claude CodeとNotion、Obsidianを使い、社内に散らばった議事録やメモを、AIが使える「会社の記憶」にまとめる内容で、全10枚あります。STEP1「つなぐ」、STEP2「ためる」、STEP3「動かす」の3段階に、よくある失敗4つ、まとめ、次の一歩が続く構成です。
夜11時、PDF10枚を渡してから納品まで
作業を始めたのは9月30日の22時56分ごろでした。前日に自己紹介動画を作ったばかりで、そのときに作った制作の型(Claude Codeのスキル「/tev」)がありました。それを流用すれば早いだろう、という見込みです。

最初のつまずきは、PDFそのものでした。開いてみると、画像だけでできていて、文字の層がありません。コピーしようとしても文字が取れない。そこでPyMuPDFというPythonの部品で、1枚ずつ288dpi(3840×2160)のPNGにしました。そのうえで、画像としてAIに読ませ、書き起こしてもらいます。
ここで1つだけ強く指示したのが「要約するな」でした。AIは親切なので、放っておくと要点をまとめてくれます。でも、研修動画の台本の元になる文字は、原文のままでなければ困る。教材の作り手が選んだ言葉を、勝手に言い換えられては意味がありません。
画像だけのPDFを書き起こすときの依頼を、そのまま使える形にしたのが次のプロンプト①です。社内のスキャン資料や、デザイナーが画像で書き出したスライドにもそのまま使えます。
あなたは研修教材の書き起こし担当です。要約・言い換え・補足は一切しないでください。
添付した画像は、研修教材のスライド【ここに枚数】枚です。
【やること】
・各スライドに書かれている文字を、書かれている順番のまま、原文どおりに書き起こす
・見出し、本文、図の中の文字、注釈を区別する
・読み取れない文字は推測で埋めず「[判読不能]」と書く
【出力形式】
スライドごとに次の形で出してください。
スライド番号:
見出し:
本文:(箇条書きは箇条書きのまま)
図の中の文字:
注釈・小さな文字:
気になった点:(切れている文字、はみ出している要素など)「気になった点」の欄は、あとで効いてきます。今回も書き起こしの段階で、10枚目のスライドのカードの最終行が枠からはみ出して切れていることが見つかりました。
PNGへの変換は、次のコード①です。ファイル名に連番を振り、各ページに文字の層があるかどうかも一緒に表示します。
# pdf_to_png.py 画像だけのPDFを1枚ずつPNGにする(PyMuPDF)
# 使い方: python pdf_to_png.py source.pdf slides
import sys
from pathlib import Path
import fitz # pip install pymupdf
src, out = Path(sys.argv[1]), Path(sys.argv[2])
out.mkdir(parents=True, exist_ok=True)
doc = fitz.open(src)
for i, page in enumerate(doc, start=1):
has_text = bool(page.get_text().strip())
pix = page.get_pixmap(dpi=288) # 16:9 のスライドなら 3840x2160
path = out / f"s{i:02d}.png"
pix.save(path)
print(f"{path.name}: {pix.width}x{pix.height} 文字の層={'あり' if has_text else 'なし'}")「文字の層=なし」が並んだら、画像として読ませる経路に切り替える合図です。
次に、流用するつもりの型紙を開きました。ここで想定外のことがわかります。前作の自己紹介動画は「ナレーター+電話応対のデモ+2人の掛け合い」という、複数の声が出てくる構成でした。コードの中に、前作専用の番号や文言がそのまま直書きされていたのです。
着手する前に、別のAI(上位モデルのレビュー役)に計画を見せました。返ってきたのは、そのまま流すと途中で止まる箇所が7つある、という指摘です。掛け合いの場面を探しにいく処理、前作にしかない番号を前提にした処理。どれも、今回の1人のナレーター構成では存在しない場面を探して止まるものでした。先にこの7つを直してから、本番の作業に入りました。
もしこの確認を飛ばしていたら、音声を何十本も作ったあとの書き出しの段階で、初めて止まっていたはずです。音声の生成には時間がかかり、回数の上限もあります。着手前に計画を別の目で見てもらうのは、遠回りに見えて、いちばん安い保険でした。
型紙は便利です。ただ、前作の名残を先に掃除しないと、便利さがそのまま事故の元になる。これが今回いちばんの学びでした。
声選びも、この夜のうちに済ませました。Gemini 3.8 Flash TTSの日本語の声は115種あります。一覧を眺めて、研修動画の案内役として設計された、低めの男性の声に決めました。話し方の指示には「急がず、はっきり、丁寧に」「押しつけがましくしない」と書いています。経営者向けの教材なので、売り込みに聞こえる声だけは避けたかったのです。
台本を作る段階で、字幕の文字と読ませる文字を分けてもらいました。Claude Codeを「クロードコード」、御社を「おんしゃ」と読ませる、といった調整です。この分け方をしておかないと、字幕にまで「クロードコード」とカタカナが出てしまう。読み違えやすい語を1つずつ拾い、読ませる側だけをかなに直していきました。
台本は49クリップ、つまり字幕49枚ぶんになりました。音声は1本ずつ、15秒の間隔をあけて順番に作ります。前日、並列でまとめて叩いたら、数分で利用の上限を使い切ってしまった反省からです。1本あたり約23秒。上限は65回に決めておきました。
誤算は尺でした。当初は約3分に収めるつもりでしたが、選んだ落ち着いた声は1文字あたり約0.2秒かけて話します。49本を並べると289.6秒、4分50秒です。削ることもできました。けれど、10枚の教材を省略せずに届けることを優先し、検証の上限を195秒から300秒に広げました。基準を動かしたのは私自身の判断です。短縮版が必要になれば、台本を削って作り直せます。
映像では、スライドを作り直さないと決めていました。原本の画像にズームと朱色の枠を重ねて、話している箇所を示す方式です。ただ、10枚目だけは例外にしました。書き起こしの段階で見つかった、カードの最終行が枠からはみ出して切れている問題です。原本のまま拡大すると、切れた文字がそのまま大写しになる。ここだけはHTMLで組み直してもらいました。
誤読チェックでは、Gemini 3.8 Flashと3.7 Flashが混雑(503)で使えず、3.6 Flashで代用しました。何度録り直しても「割れています」を「わかれて」と読む行があり、最後は字幕ごと「分かれています」に変えています。
仕上がりの映像を確かめたときのことはよく覚えていて、画面の中で落ち着いた低い声が「議事録、メモ、資料、決定事項」と一語ずつ区切って読み上げ、そのたびに朱色の枠がスライドの上をすっと移っていくのを、夜更けの部屋でひとり黙って見ていました。
数字の検査では、一度だけ不合格が出ました。最後に映るボタンの朱色が、決めていた許容範囲から少し外れたのです。原因は、動画の圧縮で色がわずかに動くことでした。画面で見比べて同じ色だと判断し、許容を広げています。
最後の数字の検査を通り、MP4が納品されたのは10月1日の0時40分ごろ。着手から約1時間45分です。0時45分には、社内のChatwork全体チャットへ共有しました。共有の文面も、YouTube用の概要欄も、AIが書いています。
声は115種から「研修の人」を選ぶ
ここからは、制作ラインを工程ごとに分けて見ていきます。まず声です。
Gemini 3.8 Flash TTSには、日本語のプリセット音声が115種あります。一覧には、性別、声の高さ、話し方の地域、そして「どんな役の人か」を表すpersonaが付いています。

今回選んだのは、personaが「Training Voiceover(Instructional Video Host)」、つまり研修動画の案内役として設計された男性の声です。名前はja-jp-training-4、設定は63歳、声は低めです。研修動画の声は、上手さよりも「聞き疲れしないこと」が大事になります。4分以上聞き続けても耳に障らない声を、personaの欄で絞り込みました。
声の一覧は機械が読める形(JSON)で取れるので、条件を渡してAIに候補を絞らせると早いです。プロンプト②は、声の一覧から3つ選ばせるときのものです。
あなたは研修動画の音声ディレクターです。
下に貼る音声の一覧(JSON)から、条件に合う声を3つ選んでください。
【動画の用途】【ここに用途:例)社内研修・経営者向け解説・約5分】
【条件】
・落ち着いていて、長く聞いても疲れない
・【ここに性別や声の高さの希望】
・persona に Training / Instructional / Narrator などの語を含むものを優先
【音声の一覧】
【ここに JSON を貼る】
【出力形式】表で出してください。
| 順位 | 声のID | 性別・高さ | persona | 選んだ理由(1行) | 気になる点 |一覧の説明文だけで決めず、上位3つは必ず同じ一文を読ませて聞き比べてください。
声が決まったら、話し方の指示を書きます。Gemini 3.8 Flash TTSは、文章と一緒に「どう読むか」を文章で指定できます。今回の指示は「社内研修動画のナレーター。落ち着いた低めの男性の声で、急がず、はっきり、丁寧に話す。語尾はやわらかく、押しつけがましくしない。」でした。この指示文を作るときのプロンプトが、次のプロンプト③です。
あなたは音声演出の担当です。
次の動画のナレーター用に、音声合成へ渡す「話し方の指示文」を1つ作ってください。
【動画の内容】【ここに内容を2〜3行で】
【見る人】【ここに対象:例)中小企業の経営者と管理職】
【避けたい印象】【ここに例:売り込み口調、早口、過度に明るい】
【条件】
・日本語で2〜3文、合計80字以内
・役割(誰として話すか)、声の質、速さ、語尾の扱いを必ず含める
・感情を大げさにする言葉は使わない
【出力形式】
指示文:
この指示で想定している話し方(1行):指示文は台本の全行で同じものを使い、途中で変えないでください。行ごとに声の調子がぶれます。
実際に1文を読ませるのが、次のコード②です。Interactions APIに文章と話し方の指示、声のIDを送り、返ってきた音声をWAVで保存します。APIキーは環境変数から読みます。
// tts_one.mjs Gemini 3.8 Flash TTS で1文を読ませて WAV に保存する
// 使い方: GEMINI_API_KEY=... node tts_one.mjs "読ませる文" out.wav
import fs from 'node:fs';
const [text, out] = process.argv.slice(2);
const style = '社内研修動画のナレーター。落ち着いた低めの男性の声で、急がず、はっきり、丁寧に話す。語尾はやわらかく、押しつけがましくしない。';
const body = {
model: 'gemini-3.8-flash-tts',
input: [{ type: 'user_input', content: [{ type: 'text', text, annotations: [{ type: 'speech_metadata', style }] }] }],
response_format: { type: 'audio' },
generation_config: { speech_config: [{ voice: 'ja-jp-training-4' }] },
};
const res = await fetch('https://generativelanguage.googleapis.com/v1beta/interactions', {
method: 'POST',
headers: { 'x-goog-api-key': process.env.GEMINI_API_KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
const json = await res.json();
// 応答の steps から音声を取り出す(RIFF で始まれば WAV、そうでなければ 24kHz の生PCM)
const audio = (json.steps || []).filter((s) => s.type === 'model_output').flatMap((s) => s.content || []).find((c) => c.type === 'audio');
let buf = Buffer.from(audio.data, 'base64');
if (buf.slice(0, 4).toString() !== 'RIFF') buf = pcmToWav(buf, 24000);
fs.writeFileSync(out, buf);
function pcmToWav(pcm, rate) {
const h = Buffer.alloc(44);
h.write('RIFF', 0); h.writeUInt32LE(36 + pcm.length, 4); h.write('WAVE', 8);
h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
h.writeUInt32LE(rate, 24); h.writeUInt32LE(rate * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
h.write('data', 36); h.writeUInt32LE(pcm.length, 40);
return Buffer.concat([h, pcm]);
}応答が生のPCMで返ることがあるため、WAVの見出し(44バイト)を自分で付ける処理を必ず残してください。
台本は「見せる文字」と「読ませる文字」の2列で書く
台本づくりで一番効いたのが、各行に2つの列を持たせる書き方です。1つは画面に出す字幕の文字(display)。もう1つは音声合成に読ませる文字(tts)です。
なぜ分けるのか。音声合成は、漢字や英字を読み間違えます。たとえば「Claude Code」は字幕ではそのまま見せたいのに、読ませるときは「クロードコード」と書いたほうが確実です。同じように、台帳は「だいちょう」、口述は「こうじゅつ」、23時30分は「にじゅうさんじ、さんじゅっぷん」、御社は「おんしゃ」、ステップ1は「ステップワン」と、読ませる列だけをかなにしました。見る人には正しい表記を、耳には正しい読みを。2列に分けると、この2つを両立できます。
字幕は1枚34字以内に決めました。2行に収まる長さです。49行の台本は、書き起こしから一気に作りました。同じことを頼む依頼文がプロンプト④です。
あなたは研修動画の台本作家です。
下に貼る教材の書き起こしを、ナレーション台本の表にしてください。内容を足したり削ったりしないでください。
【教材の書き起こし】
【ここに書き起こしを貼る】
【ルール】
・1行=字幕1枚。display は34字以内(2行に収まる長さ)
・スライド1枚につき3〜6行を目安に、教材の順番どおり並べる
・display は画面に出す文字(正しい表記のまま)
・tts は読み上げ用の文字(この段階では display と同じでよい)
・scene はスライドの番号または場面名
【出力形式】JSON の配列で出してください。
[{"id": "場面の略号_連番", "scene": "場面名", "display": "字幕の文", "tts": "読み上げ用の文"}]
最後に、行数の合計と、34字を超えそうだった行の一覧を書いてください。出てきた表は、教材の原文と並べて、抜けている段落がないかを先に確かめてください。
次に、tts列だけを「読み違えにくい形」に直します。プロンプト⑤です。
あなたは音声合成の読み調整の担当です。
下の台本(JSON)の tts 列だけを書き換えてください。display 列は1文字も変えないでください。
【書き換えるもの】
・英字の製品名・サービス名 → カタカナ(例:Claude Code → クロードコード)
・読みが複数ある漢字、専門用語 → ひらがな(例:台帳 → だいちょう)
・時刻・数字・番号 → 読みどおりのかな(例:23時30分 → にじゅうさんじ、さんじゅっぷん)
・敬称や業界の言い回し → 意図した読みのかな(例:御社 → おんしゃ)
・意味の切れ目に読点を足してよい(ゆっくり読ませたい箇所)
【台本】
【ここに JSON を貼る】
【出力形式】
書き換えた JSON 全体と、その下に「変更した語の一覧」を表で。
| 行ID | 元の表記 | 読ませ方 | 理由 |全部をかなにすると抑揚が崩れるので、読み違えそうな語だけに絞るのがコツです。
全49行の音声を作るのが、次のコード③です。15秒の間隔をあけて1本ずつ作り、送信回数の上限(今回は65回)を超えたら止まります。モデル、声、話し方、文、take番号の5つからハッシュを作り、同じ条件の音声はAPIを呼ばずに使い回します。録り直したい行はtake番号を1つ増やすだけで、その行だけが作り直されます。
// gen_all.mjs 台本の全行を「直列・15秒間隔」で生成。同じ条件の音声は作り直さない
import fs from 'node:fs';
import crypto from 'node:crypto';
import { synth } from './synth.mjs'; // コード②の処理を synth(text, style, voice) → WAV Buffer にまとめたもの
const script = JSON.parse(fs.readFileSync('script.json', 'utf8'));
const MODEL = 'gemini-3.8-flash-tts', VOICE = 'ja-jp-training-4';
const LIMIT = 65; // 送信回数の上限(超えたら止める)
const GAP_MS = 15000; // 1本送るごとに15秒あける
fs.mkdirSync('audio', { recursive: true });
let sent = 0;
for (const it of script.items) {
// キャッシュのキー=モデル+声+話し方+文+take番号
const key = crypto.createHash('sha1')
.update(JSON.stringify({ model: MODEL, voice: VOICE, style: script.narration_style, text: it.tts, take: it.take }))
.digest('hex').slice(0, 12);
const file = `audio/${it.id}.${key}.wav`;
if (fs.existsSync(file)) continue; // 同じ条件ならAPIを呼ばない
if (++sent > LIMIT) throw new Error(`送信上限 ${LIMIT} 回に到達`);
const wav = await synth(it.tts, script.narration_style, VOICE);
fs.writeFileSync(file, wav);
console.log(`生成 ${it.id}(今回 ${sent} 回目)`);
await new Promise((r) => setTimeout(r, GAP_MS));
}今回の実績は、送信53回(49本+録り直し4回)で失敗0でした。上限は「使い切らないための柵」として、必要数より少し多めに置いてください。
生成した音声には、前後に無音が付いてきます。そのまま並べると間延びするので、コード④で削ります。10ミリ秒ごとに音量を測り、-45dBFSより小さい区間を無音とみなします。声の頭が切れないよう前に40ミリ秒、語尾の余韻のために後ろに120ミリ秒を残します。
// trim.mjs 前後の無音を削る(-45dBFS 未満の10ms窓を無音とみなし、前40ms・後120msは残す)
// samples は 16bit モノラルの Int16Array
export function trimSilence(samples, rate, th = -45, pre = 0.04, post = 0.12) {
const n = Math.round(rate * 0.01); // 10ms 窓
const db = [];
for (let i = 0; i + n <= samples.length; i += n) {
let acc = 0;
for (let j = 0; j < n; j++) { const v = samples[i + j] / 32768; acc += v * v; }
db.push(10 * Math.log10(acc / n + 1e-12)); // 窓ごとの RMS を dBFS に
}
const a = db.findIndex((d) => d >= th);
if (a < 0) return samples; // 全部無音ならそのまま返す
const b = db.length - 1 - [...db].reverse().findIndex((d) => d >= th);
const s0 = Math.max(0, a * n - Math.round(pre * rate));
const s1 = Math.min(samples.length, (b + 1) * n + Math.round(post * rate));
return samples.slice(s0, s1);
}後ろを削りすぎると語尾が切れて聞こえるので、残す長さは前より後ろを長めにしてください。削った結果は trim/行ID.wav として保存し、コード⑥で並べます(WAVの読み書きは、コード②と同じ44バイトの見出しを使う小さな部品 wav.mjs にまとめています)。
スライドは作り直さず、ズームと枠で語る
映像は、原本の画像(1〜9枚目)をそのまま土台にしました。そのうえで、ナレーションが話している箇所へカメラを寄せます。寄せる倍率は最大1.3倍。話している箇所には朱色(#FB4113)の枠を付け、枠の外を少しだけ暗くします。視線を1か所に集めるための演出です。

焦点は、台本の49行それぞれに1つずつ定義しました。書き方は、スライド全体を1としたときの比率で[x, y, 幅, 高さ]。ピクセルではなく比率にしておくと、画像の解像度が変わっても同じ場所を指せます。STEP1〜3の頭では、朱色のパネルが画面を横切って、章の切り替わりを知らせます。10枚目だけは、原本の時点でカードの最終行が枠からはみ出して切れていたので、HTMLで組み直しました。
49行ぶんの座標を手で測るのは大変なので、AIにスライド画像を見せて出させます。プロンプト⑥です。
あなたは解説動画のカメラ担当です。
添付したスライド画像と、下の台本(このスライドで読む行だけ)を見て、各行で「画面を寄せる場所」を決めてください。
【台本】
【ここに行IDと display を貼る】
【ルール】
・場所はスライド全体を1とした比率で [x, y, 幅, 高さ] で表す(左上が0,0)
・その行で話している文字や図がちょうど入る大きさにする
・スライド全体を見せたい行は null にする
・隣り合う行で場所が大きく飛びすぎないようにする
【出力形式】JSON で出してください。
{"行ID": [x, y, 幅, 高さ], "行ID": null}
最後に、判断に迷った行があれば理由を1行ずつ書いてください。返ってきた座標は、枠だけを描いた確認画像を作って、必ず目で重なりを確かめてください。
画面の組み立ての心臓部が、時刻tを渡すと画面が1つに決まる関数renderAt(t)です。CSSのアニメーション、乱数、現在時刻は使いません。同じtなら何度呼んでも同じ絵になるので、何度書き出しても同じ動画ができます。コード⑤は、焦点の比率からカメラの倍率と中心を計算し、renderAt(t)で滑らかに移す部分です。
// 焦点 [x, y, 幅, 高さ](スライドに対する比率)→ カメラ(倍率 z と中心 cx, cy)
const ZMAX = 1.3;
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
const lerp = (a, b, k) => a + (b - a) * k;
const ease = (k) => (k < 0.5 ? 4 * k ** 3 : 1 - (-2 * k + 2) ** 3 / 2);
function cam(r) {
if (!r) return { z: 1, cx: 0.5, cy: 0.5 }; // null は全体表示
const z = clamp(Math.min(0.86 / r[2], 0.8 / r[3]), 1, ZMAX); // 枠が画面の86%×80%に収まる倍率(上限1.3)
const hw = 0.5 / z; // 端がはみ出さないよう中心を寄せる
return { z, cx: clamp(r[0] + r[2] / 2, hw, 1 - hw), cy: clamp(r[1] + r[3] / 2, hw, 1 - hw) };
}
// 時刻 t → 画面がひとつに決まる。乱数・現在時刻・CSSアニメは使わない
// CUES(各行の開始時刻)と FOCUS(行ID→焦点)はタイムラインと台本から読み込んでおく
window.renderAt = function (t) {
const kf = CUES.map((c) => ({ t: c.start - 0.25, r: FOCUS[c.id] ?? null })); // 話し始めの0.25秒前から動かす
let k = -1;
for (let i = 0; i < kf.length; i++) if (t >= kf[i].t) k = i;
const cur = kf[k] ?? { t: -1e9, r: null }, prev = kf[k - 1] ?? { r: null };
const b = ease(clamp((t - cur.t) / 0.7)); // 0.7秒かけて次の焦点へ
const c0 = cam(prev.r), c1 = cam(cur.r);
const z = lerp(c0.z, c1.z, b), cx = lerp(c0.cx, c1.cx, b), cy = lerp(c0.cy, c1.cy, b);
const W = 1568, H = 882; // スライド枠の大きさ(px)
slide.style.transform = `translate(${W / 2 - cx * W * z}px, ${H / 2 - cy * H * z}px) scale(${z})`;
};スライド画像のCSSは transform-origin: 0 0(左上基準)にしておかないと、計算した位置とずれます。
音を並べて、1コマずつ撮る
音声と映像をつなぐのがタイムラインです。削った音声クリップを、決まった間隔で1本の線の上に並べます。各クリップの開始と終了の時刻が、そのまま字幕を出す時刻になり、カメラを動かす時刻にもなります。

コード⑥は、49本のクリップを並べて1本のWAVにし、字幕のキューを作るものです。同じ場面の中は短い間、場面が変わるときは少し長い間をあけます。字幕は、次の字幕までの空きが短いときは、次が出るまで表示を続けます。ちらつき防止です。
// timeline.mjs トリム済みクリップを間隔つきで並べ、1本のWAVと字幕キューを作る
import fs from 'node:fs';
import { readWav, writeWav } from './wav.mjs'; // 16bit・モノラルWAVの読み書き(自作の小さな部品)
const RATE = 24000;
const script = JSON.parse(fs.readFileSync('script.json', 'utf8'));
const SAME = 0.32, CHANGE = 0.6, HEAD = 0.5, TAIL = 3.2; // 同じ場面の間/場面が変わる間/冒頭/末尾(秒)
let t = 0, prev = null;
const items = [];
for (const it of script.items) {
t += prev ? (prev.scene === it.scene ? SAME : CHANGE) : HEAD;
const s = readWav(`trim/${it.id}.wav`).samples;
items.push({ it, start: t, end: t + s.length / RATE, s });
t += s.length / RATE; prev = it;
}
const total = t + TAIL;
const mix = new Int16Array(Math.round(total * RATE));
for (const x of items) mix.set(x.s, Math.round(x.start * RATE));
writeWav('narration.wav', mix, RATE);
// 字幕キュー:次の字幕まで0.6秒未満なら、次が出るまで表示を続ける
const cues = items.map((x, i) => {
const next = items[i + 1];
const until = next && next.start - x.end < 0.6 ? next.start : x.end + 0.25;
return { id: x.it.id, text: x.it.display, start: +x.start.toFixed(3), end: +x.end.toFixed(3), until: +until.toFixed(3) };
});
fs.writeFileSync('timeline.json', JSON.stringify({ total, cues }));間の長さは、声の速さで印象が大きく変わります。数値は実際に通しで聞いてから決めてください。
書き出しは、ブラウザで作った画面を1コマずつ撮影して動画にします。Playwrightというブラウザ操作の道具でrenderAt(t)を呼び、撮った画像をそのままffmpegに流し込みます。30fps×289.6秒で約8,700コマ。画像はJPEG品質95で撮り、H.264の動画にします。区間を4つに分けて4並列で書き出し、最後につなげました。コード⑦がその1区間ぶんです。
// render_seg.mjs renderAt(t) を1コマずつ撮って ffmpeg にパイプ(30fps・JPEG品質95)
// 使い方: node render_seg.mjs 開始秒 終了秒 seg_0.mp4
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';
const FPS = 30;
const [from, to, out] = [Number(process.argv[2]), Number(process.argv[3]), process.argv[4]];
const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'mjpeg', '-i', '-',
'-vf', 'scale=in_range=full:out_range=tv:in_color_matrix=bt601:out_color_matrix=bt709,format=yuv420p',
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', out],
{ stdio: ['pipe', 'inherit', 'inherit'] });
const done = new Promise((ok) => ff.on('close', ok));
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 });
await page.goto(new URL('./player/index.html', import.meta.url).href);
await page.evaluate(() => window.__ready); // 画像とフォントの読み込み完了を待つ
for (let f = Math.round(from * FPS); f < Math.round(to * FPS); f++) {
await page.evaluate((t) => window.renderAt(t), f / FPS);
const jpg = await page.screenshot({ type: 'jpeg', quality: 95 });
if (!ff.stdin.write(jpg)) await new Promise((r) => ff.stdin.once('drain', r));
}
ff.stdin.end();
await browser.close();
await done;4つの区間ファイルは、ffmpegのconcat(-c copy)で再圧縮せずにつなぐと画質が落ちません。
誤読は「台本を見せない耳」で拾う
音声合成でいちばん怖いのは誤読です。自分で49本を全部聞き直すのは大変ですし、聞き慣れてくると間違いを聞き流します。そこで、AIに耳の役をさせました。
やり方は二段構えです。まず、読ませたい文(tts列)をひらがなに直しておきます。次に、生成した音声を台本を見せずにAIへ渡し、聞こえたとおりにひらがなで書き起こさせます。台本を見せないのが肝心で、見せると、AIは台本に引っ張られて「正しく聞こえたこと」にしてしまいます。

音声を書き起こさせるときのプロンプト⑦です。
あなたは音声の書き起こし担当です。台本や元の文章は渡しません。
添付した音声で話されている日本語を、聞こえたとおりに書き起こしてください。
【ルール】
・すべてひらがなで書く(漢字に直さない)
・英語や英字は、聞こえた読みをカタカナで書く
・数字も、聞こえた読みをひらがなで書く
・言い直しや聞き取りにくい箇所も、推測で整えずに聞こえたまま書く
【出力形式】
書き起こしの文だけを1行で出してください。説明は不要です。音声は1行ぶんずつ送ってください。どの行で読みがずれたのかを、そのまま特定できます。
読ませたい文のほうをひらがなにするのが、プロンプト⑧です。
あなたは日本語の読みの担当です。
次の文を、読み上げたときの読みどおりに、すべてひらがなに書き換えてください。
【ルール】
・漢字、数字、記号、英字もすべて読みのひらがなにする
・英字や英単語は、日本語で一般的な読みをひらがなで書く
・句読点は残してよい
【文】
【ここに文を貼る】
【出力形式】
書き換えた文だけを出力してください。同じ文は結果を保存して使い回すと、呼び出し回数を節約できます。
2つのひらがなを並べて、違う箇所だけを抜き出すのがコード⑧です。カタカナをひらがなに寄せ、記号と長音を落としてから比べます。
# kana_diff.py 「読ませたい読み」と「聞こえた読み」をひらがなで比べ、違う箇所だけ出す
# 使い方: python kana_diff.py expected.json heard.json (どちらも {"行ID": "ひらがな文"} の形)
import difflib
import json
import re
import sys
from pathlib import Path
def norm(s: str) -> str:
s = "".join(chr(ord(c) - 0x60) if "ァ" <= c <= "ヶ" else c for c in s) # カタカナ→ひらがな
return re.sub(r"[\s、。,.,.!!??・『』「」()()ー〜~\-::;;\"'“”…]", "", s) # 記号と長音を落とす
exp = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
got = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
same = 0
for cid, e in exp.items():
a, b = norm(e), norm(got.get(cid, ""))
if a == b:
same += 1
continue
ops = difflib.SequenceMatcher(None, a, b).get_opcodes()
diffs = [f"{a[i1:i2] or '∅'}→{b[j1:j2] or '∅'}" for tag, i1, i2, j1, j2 in ops if tag != "equal"]
print(cid, " / ".join(diffs))
print(f"一字一句一致: {same}/{len(exp)}")差分が出た行は、そのまま誤読と決めつけず、次の仕分けに回してください。
結果は、49本中36本が一字一句一致でした。残りの13本を見ると、9本は「は」と「わ」、「へ」と「え」のような表記の違いで、読みは合っています。4本は、別のモデルでもう一度書き起こし、正しく読めていることを確かめました。本当の誤読は2本でした。
1本は「置き場所が4つに割れています」です。何度録り直しても「わかれて」と読みます。tts列をかなで「われて」にすると、今度は「われられて」と聞こえる。ここは粘らず、字幕ごと「4つに分かれています」に変えました。もう1本は「クロード」が「クラウド」に聞こえた行で、録り直しで直りました。「AI導入前診断」の「前」を「まえ」と読むか「ぜん」と読むかは、機械では決めきれず、いま人の耳で確認しています。
13本を「表記の違い」と「本物の誤読」に分ける作業も、AIに下書きさせました。プロンプト⑨です。
あなたは音声品質の検査担当です。
下の表は「読ませたかった読み」と「音声を書き起こした読み」の差分です。各行を分類してください。
【差分】
【ここに 行ID/読ませたかった読み/聞こえた読み を貼る】
【分類の基準】
A:表記の違い(「は/わ」「へ/え」「を/お」など、発音は同じ)
B:書き起こし側の聞き違いの疑い(別の書き起こしで確認すべき)
C:本物の誤読(音声の読みが違う。録り直しか表記の変更が必要)
【出力形式】表で出してください。
| 行ID | 分類 | 根拠(1行) | 次にやること |
C の行には、読みを直すための tts の書き換え案を2つ添えてください。分類がCになった行は、最後に必ず人が音声を聞いて判断してください。
完成は「数字」で決める
動画の完成を「見た感じ、よさそう」で決めると、あとで必ず困ります。今回は、合格の条件を数字で決めてから検査しました。

結果はこうです。画面は1920×1080、30fps、色の規格はBT.709。音量は-14.0 LUFS(YouTubeの基準)で、瞬間的な最大音量(トゥルーピーク)は-3.0dB。音と映像のずれは0ミリ秒(相関0.9945)。各行の声の立ち上がりの検査で、不合格は0でした。加えて、場面の途中や切り替わりから37コマを抜き出し、目で確かめています。
一度だけ、不合格が出ました。最後のボタンの色の検査です。朱色(#FB4113)から許容±8の範囲に入っているかを見たところ、青の成分が11ずれていました。鮮やかな朱色は、動画を圧縮するときの色の変換(YUV変換)で、少しだけ値が動きます。画面で見比べても同じ色に見えたので、許容を±12に広げて合格にしました。尺の上限と同じく、これも自分で基準を動かした箇所です。
音量を揃えたのがコード⑨です。ffmpegのloudnormを2回通します。1回目で今の音量を測り、2回目で測った値を渡して、音量の形を崩さずに-14 LUFSへ揃えます。
#!/usr/bin/env bash
# loudnorm_2pass.sh ナレーションを -14 LUFS に揃えて映像と合体する(2パス)
# 使い方: bash loudnorm_2pass.sh narration.wav video.mp4 final.mp4
set -euo pipefail
IN="$1"; VIDEO="$2"; OUT="$3"
TARGET="I=-14:TP=-1.5:LRA=20"
# 1パス目:いまの音量を測る(ffmpeg が最後に出す JSON の塊だけ取り出す)
M=$(ffmpeg -hide_banner -i "$IN" -af "loudnorm=${TARGET}:print_format=json" -f null - 2>&1 | sed -n '/^{/,/^}/p')
get() { echo "$M" | grep "\"$1\"" | sed -E 's/.*: "?(-?[0-9.]+)"?,?/\1/'; }
# 2パス目:測った値を渡して linear(音量の形を変えない)で整える
LN="loudnorm=${TARGET}:measured_I=$(get input_i):measured_TP=$(get input_tp):measured_LRA=$(get input_lra):measured_thresh=$(get input_thresh):offset=$(get target_offset):linear=true"
ffmpeg -y -loglevel error -i "$VIDEO" -i "$IN" \
-filter_complex "[1:a]${LN},aresample=48000,aformat=channel_layouts=stereo[a]" \
-map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "$OUT"
# 確認:Integrated(全体の音量)と True peak(最大音量)を表示
ffmpeg -hide_banner -nostats -i "$OUT" -map 0:a -af ebur128=peak=true -f null - 2>&1 | grep -E "I:|Peak:" | tail -n 2TP=-1.5は「ここを超えない」という上限の設定で、今回の実測は-3.0dBでした。ピークが高い素材では、先に軽くリミッターをかけてから通してください。
納品物は、MP4(34.8MB)、YouTube用の字幕SRT、チャプター付きの概要欄の3点です。社内共有にはひと手間要りました。Chatworkの添付は5MBまでなので、720pに縮めた共有版(5,141,946バイト=約4.9MB)を自動で作り、社内全体チャットに投稿しています。投稿後はAPIで読み戻し、ファイル名とサイズが一致するかを確かめました。コード⑩です。
// share_chatwork.mjs 720p・5MB以下の共有版を作り、Chatworkに添付投稿して読み戻す
// 使い方: CHATWORK_API_TOKEN=... CHATWORK_ROOM_ID=... node share_chatwork.mjs final.mp4 message.txt
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
const [src, msgFile] = process.argv.slice(2);
const room = process.env.CHATWORK_ROOM_ID, out = 'share_720p.mp4', LIMIT = 5 * 1024 * 1024;
for (let crf = 30; ; crf += 2) { // 5MBを超えたら画質(crf)を1段ずつ下げて作り直す
execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', src, '-vf', 'scale=1280:720:flags=lanczos', '-c:v', 'libx264', '-preset', 'slow',
'-crf', String(crf), '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '64k', '-movflags', '+faststart', out]);
if (fs.statSync(out).size <= LIMIT) break;
if (crf >= 40) throw new Error('5MBに収まりません');
}
const H = { 'x-chatworktoken': process.env.CHATWORK_API_TOKEN };
const fd = new FormData(); // curl だと日本語のファイル名が化けることがあるので Node の FormData で送る
fd.append('file', new Blob([fs.readFileSync(out)], { type: 'video/mp4' }), '研修動画_共有用720p.mp4');
fd.append('message', fs.readFileSync(msgFile, 'utf8').trim());
const { file_id } = await (await fetch(`https://api.chatwork.com/v2/rooms/${room}/files`, { method: 'POST', headers: H, body: fd })).json();
// 投稿直後は見つからないことがあるので、少し待ってから読み戻して名前とサイズを照合
await new Promise((r) => setTimeout(r, 4000));
const got = await (await fetch(`https://api.chatwork.com/v2/rooms/${room}/files/${file_id}`, { headers: H })).json();
console.log(Number(got.filesize) === fs.statSync(out).size ? `OK ${got.filename} ${got.filesize}` : '読み戻し未確認');トークンやルームIDはコードに書かず、必ず環境変数で渡してください。
共有の文面とYouTubeの概要欄もAIが書きました。同じことを頼む依頼文がプロンプト⑩です。タイムラインの時刻をそのまま渡せるので、チャプターの時刻がずれません。
あなたは社内広報と動画配信の担当です。
下の情報から、2つの文章を作ってください。
【動画の情報】
タイトル:【ここにタイトル】
長さ:【ここに長さ】
内容の要点:【ここに3〜5行】
場面の開始時刻:【ここに「場面名 開始秒」の一覧を貼る】
制作方法:【ここに例:ナレーションはAI音声、台本と映像はAIで制作】
【1】社内チャット向けの共有文
・200字以内、です・ます調
・誰に・何のために見てほしいかを最初の1文に
・「AI音声を使っている」ことを必ず明記する
【2】YouTube の概要欄
・冒頭2行で内容を説明する
・チャプターは「0:00 場面名」の形で、開始時刻を分:秒に直して並べる(最初は必ず 0:00)
・最後に制作方法のクレジットを1行
【出力形式】【1】と【2】を見出しで分けて出してください。チャプターの時刻は、公開前に動画を再生して2〜3か所だけ実際に飛んで確かめてください。
外注の段取りと、今回の段取りを並べる
研修動画を外に頼む場合、一般的な段取りはこうなります。台本を書いて先方と確認し、ナレーターを手配し、収録日を決めて読んでもらい、編集で映像と合わせて、字幕を付けて納品。途中で一文でも直したくなれば、収録からやり直しになることもあります。関わる人が多いぶん、日程の調整だけで時間が過ぎていきます。

今回の段取りは、PDFを渡し、途中の判断に答え、最後に数字の検査結果と動画を見る、の3つでした。一文を直したければ、その行の文字とtake番号を変えて作り直すだけです。誤読した「割れています」も、実際にそうやって直しました。
直す単位も変わります。動画はふつう「1本」で直すものですが、この作り方なら「1行」で直せます。字幕1枚ぶんの音声だけを作り直し、タイムラインを組み直して、もう一度書き出す。修正にかかる気持ちの重さが、ずいぶん軽くなりました。
いや、正確に言うと、速くなったのは「作る」工程だけです。何を伝える教材にするか、どこまで省略せずに見せるか、どの声なら社内の人が最後まで聞いてくれるか。そこを決めたのは私で、その判断にかかる時間は以前と変わっていません。基準を2か所動かしたときも、動かしてよいかを決めたのは私でした。変わったのは、判断してから形になるまでの距離です。
もう一つ変わったのは、品質の確かめ方です。動画の確認というと、通しで見て「問題なさそう」と判断するのが普通だと思います。今回は、音量、音と映像のずれ、色、声の立ち上がりを、数字で合否に分けてから見ました。目で見たのは、機械が抜き出した37コマと、判断が分かれた箇所です。見るべき場所が先に絞られているので、深夜でも見落としを心配せずに済みました。
教材は「動画になる前提」で作る
今回いちばん考えさせられたのは、元の教材の作り方でした。
10枚目のスライドは、原本の時点でカードの最終行が枠からはみ出していました。紙やPDFで見ているときは、誰も気づかなかった。動画にしようとして、ズームで寄った瞬間に目立ったのです。画像だけのPDFで文字の層がなかったことも、書き起こしのひと手間を生みました。
これから教材を作るなら、最初から「あとで動画になる」前提で作ることをおすすめします。具体的には3つです。文字の層が残る形で書き出す。1枚の中の要素を、話す順番に上から並べる。はみ出しや切れがないかを、100%表示で確かめる。この3つを守るだけで、動画化のときの手戻りがかなり減ります。
もう一つ、教材の言葉を「耳で聞いても通じるか」で見直すことです。「割れています」は、目で読めば自然な日本語でした。ところが音声にすると、何度やっても別の読みになる。耳で通じにくい言葉は、人のナレーターでも言いよどむはずです。音声合成は、そういう言葉を正直にあぶり出してくれます。
社内の研修資料は、一度作ると何年も使われます。だからこそ、PDFのまま眠らせておくのはもったいない。動画にすれば、新しく入った人が自分の時間に何度でも見返せます。制度が変われば、変わった行だけを作り直せます。
一方で、判断は手元に残します。今回も、尺を延ばすか削るか、色の許容を広げるか、誤読した一文をどう言い換えるかは、私が決めました。AIは候補を出し、結果を数字で示してくれます。最後の判断を人が持つから、安心して社内に出せる動画になる。私はそう考えています。
今日やる1つ
最後に、今日やることを1つだけ挙げます。社内で「読んでおいて」と渡しているPDFを1本選び、この記事のプロンプト①で書き起こしてみてください。
動画にするかどうかは、そのあとで決めればいい。書き起こした文字を眺めると、どこが耳で通じにくいか、どこが省略されがちか、どこが古くなっているかが見えてきます。それが見えた時点で、教材はもう半分、動画になる準備ができています。
書き起こしができたら、次は声を1つ選び、最初の3行だけ読ませてみてください。そこで「これなら社員が最後まで聞いてくれる」と思えたら、残りはこの記事の順番どおりに進めれば形になります。
ちなみに、あの声の設定は63歳の研修ナレーターです。深夜に聞くと、妙に落ち着きます。

PDFを渡してから約1時間45分で、人の声が一秒も入っていない4分50秒の動画が、社内の全体チャットに届きました。教材が紙の外に出ていく感覚を、ぜひ一度、御社の資料でも試してみてください。
あわせて読みたい
AIとCapCutでつくる15秒縦型動画──企画から書き出し検証まで、迷わない実務設計
https://note.com/comix_ceo162230/n/n6266bfeef2da
Gemini 3.8を「使えるAI」にする10の実装判断――Live・Flash・Cyberを経営と現場の共通言語で設計する
https://note.com/comix_ceo162230/n/n395e0d069763
Claude Codeを「社長の右腕」にする100の実践――中小企業のための業務委任・自動化・権限設計
https://note.com/comix_ceo162230/n/nefa267965ecf
自己紹介
株式会社コミクス代表取締役の鈴木章裕です。営業の叩き上げで25年、会社を創業して18年、これまでのお取引は1,825社になりました。いまは「生成AI活用顧問」として、経営者の隣でAIの選び方から現場への定着までを伴走しています。自社でもClaude CodeとCodexを毎日使い、社長の仕事をどこまでAIに渡せるかを実験し続けています。フルマラソン完走71回、100kmウルトラマラソン完走17回。「続けること」がいちばんの武器だと思っています。
鈴木章裕
株式会社コミクス 代表取締役