こんにちは。
皆さんは解説動画とかは見たりしますか?
私は「モチベーションがそんな高くないものが、とりあえず触りだけ知っておきたい」というものは時々見たりしています。
今回は、PDFなどの手持ちのコンテンツをもとに、全自動でキャラクターによる解説動画を作るツールを作ったので自慢しようと思います。
実装
全体像
この矢印の部分
- 青 : PDF2Markdown
- 橙 : Geminiによる台本作成
- 赤 : TTS・音声生成
- 緑 : Geminiによるスライド作成
- 紫 : 動画生成
について、それぞれ説明していきます。
PDF2Markdown
一度PDFをMarkdownに変換します。
これは、2つの理由によるものです
- 一度MarkdownにしたほうがLLMが解釈しやすい......気がする......
- 変換の過程で、PDFから画像を抽出することになる。そのため、動画のスライドに画像をいれることができる
PDFの変換ですが、基本はMistral-OCRというサービスを使っています。
これは、PDFをMarkdownに変換するAPIなのですが、十分な無料枠もあり、精度もかなりいいため重宝しています。
client = Mistral(api_key=ZettaiHimitsu)
uploaded_file = client.files.upload(
file={"file_name": pdf_path.stem, "content": pdf_path.read_bytes()},
purpose="ocr",
)
signed_url = client.files.get_signed_url(file_id=uploaded_file.id, expiry=1)
pdf_response = client.ocr.process(
document=DocumentURLChunk(document_url=signed_url.url),
model="mistral-ocr-latest",
include_image_base64=True
)
texts = [page.markdown for page in pdf_response.pages]
自分は、論文を解説動画にすることが多いので、arXivの場合だけはHTMLでみる機能からmarkdownを作成するようにしています。
最後に、論文中で出てくるすべての画像をAPIをつかいGYAZOにアップロードし、そのURLをMarkdown中の画像リンクに置き換えています。
これは、2つの理由からです。
- GeminiにMarkdownを参照させる場合、URLのほうが都合がいい
- 後続のスライド作成で、ローカルファイルを参照する処理と、URLの外部ファイルを参照する処理を分けるのが面倒
TTS・音声生成
テキストを元に、その読み上げを行うツールをTTS(text-to-speech)と呼びます。
「ゆっくり」「ずんだもん」「NHKのアナウンサー」などが有名でしょうか。
キャラの声さえできれば、なんか話しているっぽい動画と組み合わせると、それっぽい動画ができます。
今回は、paler-ttsを日本語むけに追加学習したモデルである、 japanese-parler-tts-miniをファインチューニングすることで、特定のキャラの声と感情を再現しました。
2025年8月現在はTTSの学習・推論は合法です
しかしながら、TTSの利活用に対して、エンタメ関係の業界団体が反対声明をだし、利活用を制限するよう法整備を働きかけているため、法律を確認してから行ってください
このモデルは
- prompt: 喋らす内容。ルビを角括弧で括る
- 例: 吾輩[わがはい]は猫[ねこ]である
- description: 声質
- 例: A female speaker with a slightly high-pitched voice delivers her words at a moderate speed with a quite monotone tone in a confined environment, resulting in a quite clear audio recording.
の2つの引数から音声ファイルを作成します
そのためファインチューニングでは、
- prompt : 音声ファイルの文字起こし
- description : 「キャラ名」, 「感情」
- 音声ファイル
の3つをセットにすることで学習させ、安定した声質での音声生成を実現しました。
prompt_tokenizer = AutoTokenizer.from_pretrained(
"チューニング済みモデルのパス",
subfolder="prompt_tokenizer",
device="cuda",
dtype=torch.bfloat16
)
prompt_input_ids = prompt_tokenizer(
[text], return_tensors="pt", padding=True
).input_ids.to("cuda")
description_tokenizer = AutoTokenizer.from_pretrained(
"チューニング済みモデルのパス",
subfolder="description_tokenizer",
device="cuda",
dtype=torch.bfloat16
)
input_ids = description_tokenizer(
[f"{chara}, {toon}"], return_tensors="pt", padding=True
).input_ids.to("cuda")
model = (
ParlerTTSForConditionalGeneration
.from_pretrained("チューニング済みモデルのパス")
.to("cuda")
.to(torch.bfloat16)
.eval()
)
model = torch.compile(
model,
mode="max-autotune",
fullgraph=True,
dynamic=True,
)
accelerator = Accelerator(mixed_precision="bf16")
model = accelerator.prepare(model)
with torch.inference_mode():
generations = model.generate(input_ids=input_ids, prompt_input_ids=prompt_input_ids)
for audio_arr in generations:
audio_arr = audio_arr.to(torch.float32).cpu().numpy()
sf.write("TTS結果.wav", audio_arr, model.config.sampling_rate)
Geminiによる台本作成
先ほど変換したmarkdownをもとに、動画の台本を作成します。
台本は、無料枠があるので、gemini-2.5-flashのAPIで作らせています。
いわゆる「ゆっくり解説」のような、先生のような知識を持った「話し手」と、いい塩梅に質問をする「聞き手」の2人が会話する形式にしています。
「話し手」「聞き手」は学習元のキャラからランダムに2人選ぶようにしてます。
後段で、TTSやスライド作成を行うことを考え、structured output
機能で、構造化しつつ出力させます。
structured output
のスキーマは
- talks: 子要素(各発言)のlist型
- 話者: 基本的には「聞き手」「話し手」の2人です。章立てしており、そこの切れ目には「アイキャッチ」という話者を入れます
- 内容: 発言の内容。TTSの関係上、一文を長くなりすぎないようにプロンプトで指示しています
- tone: 発言の感情。TTSで学習時に使ったものをスキーマにして、そこから選ばせます。
という形式を取っています。
system_instruction = "ここに台本を作らせる指示を長々と書く"
tones = ["joyful", "sadness", ...] # ここに感情のトーンを列挙
output_schema = genai.types.Schema(
type=genai.types.Type.OBJECT,
required=["talks"],
properties={
"talks": genai.types.Schema(
type=genai.types.Type.ARRAY,
items=genai.types.Schema(
type=genai.types.Type.OBJECT,
required=["話者", "内容", "tone"],
properties={
"話者": genai.types.Schema(
type=genai.types.Type.STRING,
enum=["聞き手", "話し手", "アイキャッチ"],
),
"内容": genai.types.Schema(type=genai.types.Type.STRING),
"tone": genai.types.Schema(type=genai.types.Type.STRING, enum=tones),
},
),
),
},
)
generate_content_config = types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=output_schema,
system_instruction=[types.Part.from_text(text=system_instruction)],
)
file_bytes = Path("解説させる原稿.md").read_bytes()
file_b64 = base64.b64encode(file_bytes).decode("utf-8")
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=base64.b64decode(file_b64), mime_type="text/markdown")
]
)
client = genai.Client(api_key="めっちゃ秘密")
response = client.models.generate_content(
model=modelname,
contents=[content],
config=generate_content_config
)
talks = response.parsed["talks"]
一応、system_instructionを公開します
添付ファイルを元に、解説動画の台本を作成してください
解説は聞き手と話し手の2人で、話の切れ目には、アイキャッチを入れてください
話者(聞き手/話し手/アイキャッチ)、内容、toneの3つをセットにしてください
# 内容について
内容は話者の実際の話すセリフやアイキャッチの題目のみを記載してください
話す関係上、口にしづらい内容(アンダーバーなど)は使用しないでください
語尾はその時の状態に合わせて?!。…を使い分けてください
# 聞き手について
{ここに聞き手のキャラ説明を入れる}
# 話し手について
{ここに話し手のキャラ説明を入れる}
# 台本について
具体例を話に織り交ぜつつ、極力長めの尺にしてください
アイキャッチ(章立て)としては
- オープニング(雑談)
- このテーマを学ぶモチベーション説明
- 本論: 重要ポイントの解説(1個目)
- 本論: 重要ポイントの解説(2個目)
- 本論: 重要ポイントの解説(3個目、必要なら4個目以上もつくること)
- まとめ(添付ファイルの内容の簡単な要約)
としてください。
まずは雑談として、日常的な話題から自然な流れで本論に入るようにしてください
セリフは一文が150文字をこえないように工夫してください
Geminiによるスライド作成
スライドは、Geminiで各ページごとのmarkdownを出力させ、marpでスライドっぽい画像を生成しています。
markdown作成部
先程と同様に、Geminiのstructured output
機能を使って、スライドの内容を整理しています。
def get_slide_schema(talks: list, urls_img: list) -> dict:
"""
スライド生成のためのレスポンススキーマを動的に生成
"""
eye_catchs = [talk["内容"] for talk in talks if talk["話者"] == "アイキャッチ"]
# スキーマのプロパティとして、各アイキャッチのタイトルをキーとし、値は文字列型とする
# これにより、Gemini APIは各アイキャッチに対応するMarkdown文字列を生成するよう促される
properties = {}
properties["本文(箇条書き)"] = genai.types.Schema(
type=genai.types.Type.ARRAY,
items=genai.types.Schema(
type=genai.types.Type.STRING,
description="スライドの本文をmd形式で最大で5行、画像がある場合は最大2行にしてください。数式をいれる場合は前後に$をつけてください"
),
)
if len(urls_img) > 0:
properties["画像URL"] = genai.types.Schema(
type=genai.types.Type.STRING,
description="スライドに関連する画像がある場合、URLを指定してください",
enum=urls_img
)
page_schema = genai.types.Schema(
type=genai.types.Type.OBJECT,
required=["本文(箇条書き)"],
properties=properties
)
schema = genai.types.Schema(
type=genai.types.Type.OBJECT,
required=eye_catchs,
properties={eye_catch: page_schema for eye_catch in eye_catchs}
)
return schema
system_instruction = "ここにスライドを作らせる指示を長々と書く"
contents = []
for path in ["解説させる原稿.md", "先程作った台本.md"]:
file_bytes = Path(path).read_bytes()
file_b64 = base64.b64encode(file_bytes).decode("utf-8")
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=base64.b64decode(file_b64), mime_type="text/markdown")
]
contents.append(content)
urls_img = [] # PDF中に含まれる画像のファイル名リスト
generate_content_config = types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=get_slide_schema(talks, urls_img),
system_instruction=[types.Part.from_text(text=system_instruction)],
)
client = genai.Client(api_key="すっごい秘密")
response = client.models.generate_content(
model=modelname,
contents=contents,
config=generate_content_config
)
system_instructionはこんな感じ
解説内容の添付ファイルと解説台本を元に、解説動画のスライドを作成してください
スライドは各アイキャッチ事に作成し、ページ内容としては「画像URL(Optional)」と「本文(箇条書き)」の2つを持ちます
数式や画像などの、解説台本で触れにくい要素を積極的に入れてください
## 画像URL(Optional)
画像がある場合は、スライドに関連する画像のURLを指定してください。
## 本文(箇条書き)
解説内容をMarkdown形式で記載し、画像がない場合は最大5行程度、画像がある場合は最大2行程度にしてください。
各行はスライド上で一行で表示されるよう、長くなりすぎないように体言止めを用いるなど、スライドに適した表現を心がけてください。
数式を入れる場合は前後に$をつけてください。
marp
markdownができたら、marpでスライド画像を生成します。
いい感じのpythonラッパーがなかったので、subprocessで直接叩いています。
# marpでスライド画像を生成 実際はforループで各スライドごとに実行
md_path = Path("スライド.md")
png_path = Path("スライド.png")
cmd = (
f"npx --yes @marp-team/marp-cli "
f"--image png "
f"--image-scale {scale} "
f"--allow-local-files "
f'{shlex.quote(str(md_path.name))} '
f'-o {shlex.quote(str(png_path.name))}'
)
proc = subprocess.Popen(
cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(md_path.parent)
)
動画生成
最後に、台本(字幕)、音声、スライド、立ち絵を組み合わせて動画を生成します。
ツールとしてはmoviepyを使用しています。
立ち絵は事前に「喋っている動画」と「黙っている動画」の2種類を用意しておき、それぞれループ再生しています。
動画は一枚絵をベースにFramePackで動画化しました。
各発言ごとに独立して動画を作成し、最後にそれらを結合しています。
# TTS音声
audio_path = "TTSで作った音声ファイル.wav"
audio_clip = AudioFileClip(audio_path)
# スライド画像とTTS音声
slide_img_path = "marpで作ったスライド画像.png"
bg_clip = ImageClip(slide_img_path).with_duration(audio_clip.duration)
# キャラ立ち絵動画
chara_left_movie = "話し手の動画.mp4"
chara_right_movie = "聞き手の動画.mp4"
mask_color = MaskColor(color=(255, 255, 255), threshold=15, stiffness=100)
left_char_clip = VideoFileClip(chara_left_movie, has_mask=True)
left_char_clip = mask_color.apply(left_char_clip)
left_char_clip = Loop().apply(left_char_clip)
left_char_clip = (
left_char_clip
.with_position(("left", "bottom"))
.with_duration(audio_clip.duration)
)
right_char_clip = VideoFileClip(chara_right_movie, has_mask=True)
right_char_clip = mask_color.apply(right_char_clip)
right_char_clip = Loop().apply(right_char_clip)
right_char_clip = (
right_char_clip
.with_position(("right", "bottom"))
.with_duration(audio_clip.duration)
)
# 字幕
text_subtitle = "台本上の字幕で表示するテキスト"
txt_clip = TextClip(
font="GenJyuuGothic-Normal.ttf",
text=text_subtitle,
font_size=24,
color="black",
size=bg_clip.size,
method="caption",
stroke_color="キャラのテーマカラー",
stroke_width=2,
text_align="center",
horizontal_align="center",
vertical_align="bottom",
margin=(0, 10)
)
txt_clip = (
txt_clip
.with_position(("center", "bottom"))
.with_duration(duration)
)
# 各要素の合成と動画出力
clips_to_composite = [bg_clip, left_char_clip, right_char_clip, txt_clip]
video_segment = CompositeVideoClip(clips_to_composite).with_audio(audio_clip)
video_segment.write_videofile(
"できた動画.mp4",
fps=30,
codec="libx264",
audio_codec="aac",
threads=1, # 各動画ごとに独立したスレットで動かすため、スレット数は1
preset="veryslow",
ffmpeg_params=["-crf", "10", "-pix_fmt", "yuv420p", "-tune", "stillimage"]
)
動画作成の高速化
この手法は、目安としては動画の尺の2倍程度の時間がかかります。
これは、動画のエンコーディング時間や深層学習での推論時間を行っていることを考慮すると、そこまで遅いわけではないと思います。
高速化のために実行順序を工夫しました。
- バックグラウンドでTTSモデルを立ち上げてVRAMにのせる
- 台本・スライドを作成
- 各発言ごとに、forループ
- TTSで音声生成
- その発言の動画を 別スレッド で生成
- 全動画を結合
ポイントは、TTSで音声生成をしている間に、別スレッドで動画生成を行うことです。
TTSはGPUを使いますが、動画生成はCPUで行うため、両者はほとんど競合しません。
これにより、TTSと動画生成を並列化することができ、全体の処理時間を大幅に短縮できます(ほぼ2倍)。
また、全動画を結合する処理も、動画のエンコーディングを揃えておくことで、再エンコードなしで結合できるため、ここの処理の時間はほぼ気になりません。
最後に
このコード、そこそこ頑張って作ったのですが(TTSのファインチューニングに7日かかった)、悲しいことにこの記事を書いている途中にNotebookLMの解説動画生成機能が日本語対応しました。
スライドめっちゃおしゃれで、凹んでいます。