「3時間の会議録音をまるごと要約したい」「2時間の講義動画からスライド切り替わりのタイムスタンプだけ抜き出したい」——Antigravity でエージェントを書いていると、こういう要件に必ず突き当たります。
ところが、いざ実装に入ると最初の壁は「そもそもこの長さのメディアを、どうやって Gemini に渡すのか」です。base64 で丸ごと embed することはできず、かといってストリームで流すわけにもいきません。答えは Gemini File API なのですが、公式ドキュメントには各エンドポイントの仕様は書いてあっても、「実戦でどう組むか」までは踏み込まれていません。
私は過去半年で、自分の制作ログや公演記録、ASMR 配信の元音源などを AI に読ませるためのパイプラインを、Antigravity 上で何度も作り直してきました。その過程で、File API を単なるアップローダとして使うと高確率で破綻することも、タイムスタンプ付き出力を安定させるには出力スキーマ設計が9割だということも、身をもって学びました。ここではその実装知をまるごと共有します。
この記事で作るもの
完成形はシンプルです。ローカルの動画・音声・PDF を 1 本の Python スクリプトに渡すと、次の JSON が返ってくる構造です。
全体要約(400〜600 文字)
章立て(タイムスタンプ + 章タイトル + 章要約)
ハイライト(「このシーンは必見」をタイムスタンプ付きで 5〜10 件)
出典トークンの使用量とコスト(USD / JPY)
これを Antigravity のエージェントから呼び出せるようにしておけば、「長い動画を見る」という作業そのものがエージェントのタスクに変わります。私の場合、週末に録った制作メモ動画を自動で議事録化する用途で使っていますが、ひとつのパイプラインで用途は無限に広がります。
Gemini File API の位置づけ — なぜ必要なのか
Gemini を API から使うとき、プロンプトに画像や音声を混ぜる方法は大きく分けて 3 つあります。
インライン埋め込み : base64 で encode してリクエスト本体に含める方法。数十 MB 以下の画像や短い音声ならこれで十分です
File API 経由 : 一度 Google 側にファイルをアップロードし、返ってきた URI をプロンプトに渡す方法。数百 MB〜GB クラスのメディアや、同一ファイルを何度も推論に使い回したいときに有効です
YouTube URL 直接参照 : 公開 YouTube 動画なら URL を直接渡せる専用パスもありますが、これは自前コンテンツには使えません
長時間メディアを扱うときに実質選択肢になるのは File API 経由だけです。インライン埋め込みは 20 MB 程度のソフト上限があり、1 時間以上の音声を載せようとするとほぼ確実にリクエストが弾かれます。File API は最大 2 GB(2026 年 4 月時点)まで受け付け、48 時間保持され、同じファイルを何度もプロンプトで参照できます。
私の感覚では、手元の MP4 が 150 MB を超えたら迷わず File API、という運用が最も安定します。
アーキテクチャの全体像
このパイプラインは 4 段構成にします。多段にする理由は、失敗した時にどこから再実行すればいいかを明確にするためです。一撃で全部やろうとすると、最後の JSON パースでコケただけでファイルアップロードからやり直すことになり、実運用でまず破綻します。
Stage 1: ファイル取り込み(メディア→ File API → URI)
Stage 2: 全体要約(URI → 要約テキスト)
Stage 3: 構造化抽出(URI + 要約 → 章立て + ハイライトの JSON)
Stage 4: 後処理(バリデーション、フォーマット変換、コスト計算)
それぞれの中間成果物をローカルの cache/ ディレクトリに JSON で落としておけば、あとからどの段階でも再実行が可能になります。開発現場では、このキャッシュの有無が作業時間を数十倍左右します。
前提と依存関係のセットアップ
Antigravity でプロジェクトを開いたら、まず Python 環境を整えます。Google Gen AI SDK は 2026 年に入ってからアップロード API が整理され、google-genai 1.x 系が安定板です。
# .venv を作成して Gen AI SDK を入れる
python -m venv .venv
source .venv/bin/activate # Windows なら .venv\Scripts\activate
pip install "google-genai>=1.0" "pydantic>=2.5" "python-dotenv>=1.0" "rich>=13.0"
API キーは .env に置き、リポジトリに含めない運用にします。Antigravity のエージェントから呼ばせる場合も、.env をルートに置いておけば python-dotenv が自動で読みます。
# .env
GEMINI_API_KEY = YOUR_GEMINI_API_KEY
⚠️ 公開リポジトリに .env を含めないでください。私は GitHub の Secret Scanning に何度も救われています。詳しくは Antigravity と Gemini Multimodal API の連携完全実装ガイド の「シークレット管理」の章で触れています。
Stage 1: ファイル取り込みを「冪等に」実装する
ここが最も初心者が詰まる箇所です。何度も試行錯誤する開発初期で、毎回アップロードしていると時間もコストも大きな損失になります。そこで、同じファイルは同じキャッシュキーにぶら下げて再利用する実装にします。
# file_ingest.py — 冪等なアップロード
from __future__ import annotations
import hashlib
import json
import os
import time
from pathlib import Path
from google import genai
from google.genai import types
from dotenv import load_dotenv
load_dotenv()
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
CACHE_DIR = Path( ".cache/files" )
CACHE_DIR .mkdir( parents = True , exist_ok = True )
def _sha1 (path: Path) -> str :
"""ファイルの SHA-1 を返す。キャッシュキーに使う"""
h = hashlib.sha1()
with path.open( "rb" ) as f:
for chunk in iter ( lambda : f.read( 1024 * 1024 ), b "" ):
h.update(chunk)
return h.hexdigest()
def upload_or_reuse (local_path: str , display_name: str | None = None ) -> dict :
"""同一ファイルは再アップロードせず、キャッシュに保存した File オブジェクト情報を返す"""
path = Path(local_path)
key = _sha1(path)
cache_path = CACHE_DIR / f " { key } .json"
if cache_path.exists():
cached = json.loads(cache_path.read_text())
# Gemini File API は 48 時間で削除される
if time.time() - cached[ "uploaded_at" ] < 47 * 3600 :
print ( f "♻️ reuse cache: { cached[ 'name' ] } " )
return cached
print ( f "⬆️ uploading { path.name } ( { path.stat().st_size / 1024 / 1024 :.1f } MB)..." )
file = client.files.upload(
file = path,
config = types.UploadFileConfig( display_name = display_name or path.name),
)
# ACTIVE になるまで待機
while file .state.name == "PROCESSING" :
time.sleep( 2 )
file = client.files.get( name = file .name)
if file .state.name == "FAILED" :
raise RuntimeError ( f "upload failed: { file .error } " )
info = {
"name" : file .name,
"uri" : file .uri,
"mime_type" : file .mime_type,
"display_name" : file .display_name,
"uploaded_at" : time.time(),
}
cache_path.write_text(json.dumps(info, ensure_ascii = False , indent = 2 ))
print ( f "✅ ready: { file .name } " )
return info
このコードのポイントは、キャッシュの有効期限を 47 時間(File API の上限 48 時間より 1 時間短い)にしてあることです。ギリギリまで使おうとすると、推論リクエストの最中にファイルが消えるリスクがあります。私は過去に一度これで 30 分の推論を無駄にしたので、安全マージンを取るようにしています。
よくある落とし穴(Stage 1)
PROCESSING のままポーリングせずに推論を投げる : アップロード直後の数秒は File が PROCESSING 状態で、プロンプトから参照すると 400 FILE_STATE_UNSPECIFIED が返ります。必ず ACTIVE になってから次に進むこと
動画の MIME タイプを video/mp4 で決め打ちする : .mov や .webm も普通にあります。SDK に MIME 判定を任せるのが安全です
同一プロセスで並列アップロード : SDK の files.upload はスレッドセーフですが、ネットワーク帯域を食い潰すので 2 〜 3 並列までに抑えるのが実用的です
Stage 2: 全体要約を安定して得る
長時間メディアの全体要約は、後続ステージのプロンプトに埋め込むことで精度が跳ね上がります。人間が動画を見るときも、あらすじを先に知っているほうがハイライトを拾いやすいのと同じ理屈です。
# summary.py — 全体要約ステージ
from google.genai import types
from file_ingest import client
SUMMARY_MODEL = "gemini-2.5-pro" # 長時間メディアは Pro 系が安定
def summarize (file_info: dict , language: str = "ja" ) -> str :
"""アップロード済みファイルの全体要約を返す"""
system = (
"あなたはメディア要約の専門家です。"
"与えられた動画・音声・PDF の内容を、時系列に沿って 400〜600 文字で要約してください。"
"推測や一般論は書かず、メディア内で実際に述べられている事実のみを書きます。"
)
contents = [
types.Part.from_uri( file_uri = file_info[ "uri" ], mime_type = file_info[ "mime_type" ]),
types.Part.from_text( text = f "上記のメディアを { language } で要約してください。" ),
]
try :
resp = client.models.generate_content(
model = SUMMARY_MODEL ,
contents = contents,
config = types.GenerateContentConfig(
system_instruction = system,
temperature = 0.2 , # 要約は再現性重視で低め
),
)
except Exception as e:
# レート制限や一時的な失敗は呼び出し側でリトライする
raise RuntimeError ( f "summary failed: { e } " ) from e
return resp.text.strip()
temperature=0.2 にしている理由は、要約ステージで振れ幅が大きいと次の抽出ステージの精度が連鎖的に劣化するためです。私は過去に temperature=0.7 で試して、同じ動画から毎回違う要約が出てくるせいで、章立てのタイトルまで毎回ばらばらになり、キャッシュが機能しなくなった経験があります。
Stage 3: タイムスタンプ付きハイライトを構造化出力で得る
ここが本記事の核心です。「タイムスタンプと一緒に章立てを返してほしい」という要求は、プロンプトで「JSON で返して」と頼むだけでは実運用に耐えません。Structured Output と Pydantic を併用し、スキーマで強制するのが鉄則です。
# extract.py — タイムスタンプ付きハイライト抽出
from __future__ import annotations
from pydantic import BaseModel, Field
from google.genai import types
from file_ingest import client
EXTRACT_MODEL = "gemini-2.5-pro"
class Chapter ( BaseModel ):
start: str = Field( ... , description = "章の開始タイムスタンプ HH:MM:SS" )
end: str = Field( ... , description = "章の終了タイムスタンプ HH:MM:SS" )
title: str = Field( ... , description = "章タイトル 30 文字以内" )
summary: str = Field( ... , description = "章の要約 120〜200 文字" )
class Highlight ( BaseModel ):
timestamp: str = Field( ... , description = "ハイライト開始 HH:MM:SS" )
title: str
reason: str = Field( ... , description = "なぜハイライトか 60〜100 文字" )
class MediaDigest ( BaseModel ):
overall_summary: str
chapters: list[Chapter]
highlights: list[Highlight]
def extract_digest (file_info: dict , overall_summary: str , language: str = "ja" ) -> MediaDigest:
system = (
"あなたはメディアの構造抽出エンジンです。"
"渡されたメディア本体と事前要約を元に、章立てとハイライトを抽出します。"
"タイムスタンプは必ず HH:MM:SS 形式(必要なら MM:SS でも可)で、"
"メディア内で実際に到達する時刻を記述すること。架空のタイムスタンプは禁止。"
)
contents = [
types.Part.from_uri( file_uri = file_info[ "uri" ], mime_type = file_info[ "mime_type" ]),
types.Part.from_text(
text = (
f "# 事前要約 \n{ overall_summary }\n\n "
f "# 指示 \n 上記メディアを { language } で、章立てとハイライトを抽出してください。"
"章は5〜10個、ハイライトは3〜8個が目安です。"
)
),
]
try :
resp = client.models.generate_content(
model = EXTRACT_MODEL ,
contents = contents,
config = types.GenerateContentConfig(
system_instruction = system,
response_mime_type = "application/json" ,
response_schema = MediaDigest, # Pydantic をそのまま渡せる
temperature = 0.3 ,
),
)
except Exception as e:
raise RuntimeError ( f "extract failed: { e } " ) from e
# SDK が Pydantic モデルへパース済みの結果を返すのでそのまま返却
if resp.parsed is None :
raise ValueError ( "Gemini response could not be parsed into MediaDigest" )
return resp.parsed # type: ignore[return-value]
このコードが「動く JSON」を返す理由は、response_schema に Pydantic クラスを渡している点にあります。Gemini は JSON Schema に沿った出力を強制される(正確には高確率で沿わせる)ため、json.loads で例外になることがほぼなくなります。
なお「HH:MM:SS 形式で」と自然言語で書いても、モデルは 1 時間未満の動画だと MM:SS を返しがちです。これを強制的に合わせようとすると精度が逆に落ちるので、後処理で正規化する戦略の方が実用的です。
# 後処理: タイムスタンプの正規化
def normalize_ts (ts: str ) -> str :
parts = ts.split( ":" )
if len (parts) == 2 :
return "00:" + ts
return ts
よくある落とし穴(Stage 3)
出力トークン上限で JSON が途切れる : 章やハイライトを多く要求すると、返却 JSON が途中で切れます。max_output_tokens を 8192 に明示的に指定しておくのが安全です
タイムスタンプが範囲外 : 60 分の動画に対して 01:15:00 のような時刻が返ることがあります。後処理で範囲検証し、外れていたらハイライトから除外する処理を入れましょう
同じシーンが複数ハイライトになる : 30 秒以内の近接タイムスタンプは重複扱いで間引く後処理を入れると、ユーザーにとって読みやすい結果になります
Stage 4: 後処理とコスト計算
Gemini API は入力トークン数を metadata として返してくれるので、毎回のコストをログに残しておくと運用が一気に楽になります。私は「一晩で課金が想定の 20 倍になっていた」という事故を何度か経験していて、以来この後処理は必ず入れています。
# cost.py — 使用量とコスト計算
# 料金は 2026 年 4 月時点の公式レート例。最新は公式ドキュメントで要確認
PRICE_PER_1M_INPUT_USD = 1.25 # Gemini 2.5 Pro テキスト換算
PRICE_PER_1M_OUTPUT_USD = 10.0
USD_JPY = 150 # レートは環境に合わせて上書き
def estimate_cost (usage) -> dict :
"""response.usage_metadata を受け取り、推定コストを返す"""
in_tok = getattr (usage, "prompt_token_count" , 0 ) or 0
out_tok = getattr (usage, "candidates_token_count" , 0 ) or 0
usd = (
in_tok / 1_000_000 * PRICE_PER_1M_INPUT_USD
+ out_tok / 1_000_000 * PRICE_PER_1M_OUTPUT_USD
)
return {
"input_tokens" : in_tok,
"output_tokens" : out_tok,
"usd" : round (usd, 4 ),
"jpy" : round (usd * USD_JPY , 1 ),
}
エントリポイント — ここまでを 1 本にまとめる
最後にすべての段を直列に繋ぎ、CLI として叩けるようにします。失敗時の責任分界を明確にするため、各ステージの例外は個別にログへ落とします。
# main.py
import argparse
import json
from pathlib import Path
from rich.console import Console
from file_ingest import upload_or_reuse
from summary import summarize
from extract import extract_digest, normalize_ts
from cost import estimate_cost
console = Console()
def run (input_path: str , out_dir: str = "out" , language: str = "ja" ) -> None :
Path(out_dir).mkdir( parents = True , exist_ok = True )
file_info = upload_or_reuse(input_path)
console.print( "[bold cyan]Stage 2: summary[/bold cyan]" )
overall = summarize(file_info, language = language)
console.print( "[bold cyan]Stage 3: extract[/bold cyan]" )
digest = extract_digest(file_info, overall, language = language)
# タイムスタンプ正規化
for ch in digest.chapters:
ch.start = normalize_ts(ch.start)
ch.end = normalize_ts(ch.end)
for hl in digest.highlights:
hl.timestamp = normalize_ts(hl.timestamp)
out = {
"overall_summary" : overall,
"digest" : digest.model_dump(),
}
out_path = Path(out_dir) / (Path(input_path).stem + ".json" )
out_path.write_text(json.dumps(out, ensure_ascii = False , indent = 2 ))
console.print( f "💾 saved: { out_path } " )
if __name__ == "__main__" :
ap = argparse.ArgumentParser()
ap.add_argument( "input" )
ap.add_argument( "--out-dir" , default = "out" )
ap.add_argument( "--lang" , default = "ja" )
args = ap.parse_args()
run(args.input, args.out_dir, args.lang)
実行例と期待する出力は以下のとおりです。
$ python main.py ./samples/meeting_20260410.m4a --lang ja
⬆️ uploading meeting_20260410.m4a (183.5 MB )...
✅ ready: files/abc123
Stage 2: summary
Stage 3: extract
💾 saved: out/meeting_20260410.json
生成される JSON の冒頭はこうなります。
{
"overall_summary" : "本会議は 2026-04-10 に行われた、..." ,
"digest" : {
"chapters" : [
{ "start" : "00:00:00" , "end" : "00:12:30" , "title" : "議題確認とKPIレビュー" , "summary" : "..." },
...
],
"highlights" : [
{ "timestamp" : "00:47:15" , "title" : "新プランの価格合意" , "reason" : "..." }
]
}
}
本番運用で直面する3つの落とし穴
ここまでで動くパイプラインは出来ていますが、継続運用に入ると別種の問題が顔を出します。現場で実際にハマった順に書きます。
1. 48時間の保持期間を忘れて深夜バッチが死ぬ
File API のファイルは 48 時間で自動削除されます。これを忘れて、長期バッチで同じ URI を使い回す設計にすると、週次タスクが土曜日に必ず落ちる、というような厄介な事故が起きます。保持期間は短めに再計算し、参照時に ACTIVE か確認するロジックを入れましょう。私は「推論前に 1 回 files.get() で状態確認」を標準手順にしています。
2. レート制限を即時エラー扱いにして全ステージをやり直す
Gemini API は 429(レート制限)を返すことがありますが、これを即エラー扱いにすると、長時間の要約処理全体が再実行されることになります。正解は指数バックオフで 3 回までリトライし、それでも駄目な場合だけ上位に例外を返す、というガードです。tenacity や自前の小さなデコレータで十分実装できます。
3. 動画の暗部・無音区間でハイライトが出ない
ASMR や長時間配信のように、全体として動きの少ないメディアだと、モデルが「特筆すべきハイライト」を捻り出せず空配列を返すことがあります。これを「失敗」と見做すとユーザー体験が悪くなるので、「ハイライト 0 件は正常系」として UI 側で扱う設計にしておくのが安全です。
応用: 本番で使えるシナリオ
ここまでの実装は、以下のような本番ユースケースにそのまま転用できます。
議事録自動化 : 会議音声 → 章立て → 決定事項だけを抽出するプロンプトに差し替えると、そのまま議事録テンプレートになります
教材動画の検索インデックス : 講義動画の章立てと要約を Supabase に保存しておけば、「行列分解の話だけ見たい」というユーザー検索に応えられます
長時間配信のダイジェスト生成 : 3 時間の配信から 5 分のハイライト動画を作る用途。タイムスタンプ付きで返るので、ffmpeg を噛ませればダイジェスト MP4 を自動生成できます
過去のポッドキャスト回のメタデータ一括生成 : 50 エピソード分まとめて回すと、1 日でサイト全体の章立てが揃います
私自身は、撮った制作映像を Notion に流し込むまでのパイプラインでこれを使っています。Antigravity と Notion API を組み合わせたドキュメント駆動開発ガイド のルーティングに、File API ステージを挟むだけで、Notion ページに自動で章立てが付く仕組みになります。
コストとトレードオフ
長時間メディアを Pro モデルで扱うとコストは無視できません。目安として、1 時間の動画に対して Gemini 2.5 Pro で章立て+ハイライト抽出を 1 回走らせると、おおむね 0.2〜0.5 USD 程度です(入力トークンが 30〜100 万、出力が 2〜5 万の想定)。
コスト優先 : Stage 2 の要約を Flash 系モデルに落とし、Stage 3 のみ Pro を使う設計にすると、総コストを半分前後まで削れます
精度優先 : Stage 2 も Pro にした上で、章ごとに再要約する 2 パス方式に。議事録のような精度が命の用途向けです
速度優先 : 音声だけなら 48 kHz モノラル wav より 16 kHz mp3 の方がアップロード時間が数倍速いです。音質と処理時間のバランスは必ず試して決めましょう
プロンプト単価は公式の最新ドキュメントで都度確認してください。私は月初に一度 Antigravity Python SDK 本番運用マスターガイド で紹介しているコスト監視ダッシュボードで実績を見直しています。
関連して学んでおくと強い領域
今回の実装を足場にして、次のステップに進むなら以下の領域がつながりやすいです。
File API のような個別仕様よりも、システムとしての組み立て方に悩んでいる方におすすめです。
次の一歩
明日、仕事の合間にでも試せる一歩として、手元に溜まっている 30 分以上の動画・音声をひとつ選び、このパイプラインに通してみてください。Gemini が章立てを返す瞬間、長時間メディアに対する「読めない」という諦めが、少し変わるのが体感できるはずです。
そしてもし、このスクリプトを自社の社内用途に組み込みたいと感じたら、まずは Stage 1 のキャッシュ設計だけ拡張して、S3 か Cloudflare R2 にファイルを保存する実装に差し替えてみてください。そこから先の運用自動化は、実はほとんど今日書いたコードの延長線上にあります。