取り組みの背景:マルチモーダル AI がアプリ開発を変える
2026年現在、AI アプリ開発における最大のパラダイムシフトのひとつが「マルチモーダル」への移行です。テキストだけを処理していた時代から、画像・音声・動画・PDF ドキュメントを統合して理解するシステムへ。この変化は、個人開発者にとっても大きなビジネスチャンスをもたらしています。
Antigravity IDE と Gemini API を組み合わせることで、以前は大規模チームにしか実現できなかったマルチモーダル AI アプリを、一人の開発者が短期間で構築・本番リリースできる時代になりましました。
ここでは実際に動作するコードと設計パターンを通じて、以下を体系的に習得します。
- Gemini API のマルチモーダル機能(Vision・Audio・Document)の統合方法
- Antigravity を使った効率的な実装フロー
- 本番環境で必要なエラーハンドリングとコスト最適化
- 実用的なユースケース(レシート解析・音声メモ変換・ドキュメント要約)の完全実装
対象読者は、Antigravity の基本操作と JavaScript/TypeScript の基礎知識があり、次のステップとして AI 機能を本格的にプロダクトへ組み込みたい方です。
Gemini API マルチモーダル機能の全体像
Gemini API が提供するマルチモーダル機能を理解することから始めましょう。2026 年現在、主要なモデルとその特性は次の通りです。
Gemini 2.5 Pro 最高精度のマルチモーダル理解を提供します。複雑な画像の詳細分析、長編ドキュメントの要約、微妙なニュアンスを含む音声の文字起こしに適しています。ただし、コストと応答時間が高めのため、精度が最優先される処理に使用します。
Gemini 2.5 Flash 速度とコストのバランスに優れたモデルです。リアルタイムに近い応答が必要なユースケースや、大量処理が必要なバッチ処理に最適です。多くの本番環境で主力となるモデルです。
Gemini 2.0 Flash Lite 最もコスト効率の高いモデルです。シンプルな画像分類や短いテキスト変換など、軽量な処理に適しています。
マルチモーダルの主要ユースケースを整理しましょう。
- 画像 → テキスト: OCR・レシート解析・商品説明生成・アクセシビリティ alt テキスト
- 音声 → テキスト + 分析: 会議の文字起こし・感情分析・要約
- PDF/文書 → 構造化データ: 請求書解析・契約書レビュー・研究論文要約
- 動画 → インサイト: 商品デモのハイライト抽出・教育コンテンツの要約
- 複合入力: 「この画像と音声メモを合わせてレポートを生成する」など
Antigravity 環境セットアップと API 設定
プロジェクト構成
まず Antigravity で新しいプロジェクトを作成し、必要な依存関係をインストールします。
# Antigravity のターミナルで実行
npm create next-app@latest multimodal-ai-app -- --typescript --tailwind --app
cd multimodal-ai-app
npm install @google/generative-ai @google/genai
npm install zod react-dropzone環境変数の設定
プロジェクトルートに .env.local を作成します。Antigravity の AI エージェントが誤って実際のキーをコードに埋め込まないよう、プロンプトで明示的に指示する点が肝心です。
# .env.local
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
NEXT_PUBLIC_MAX_FILE_SIZE=10485760 # 10MBAntigravity のプロジェクトルールファイル(.antigravity/rules.md)に以下を追記します。
# セキュリティルール
- 環境変数はコードに直接書かない。必ず process.env を使用する
- API キーを含むファイルは絶対にコミットしない
- クライアントサイドで GEMINI_API_KEY を参照しない(サーバーサイドのみ)Gemini クライアントの初期化
src/lib/gemini.ts を作成します。
import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai";
if (!process.env.GEMINI_API_KEY) {
throw new Error("GEMINI_API_KEY is not set in environment variables");
}
// モデルごとのクライアントを遅延初期化でキャッシュ
const clients = new Map<string, ReturnType<GoogleGenerativeAI["getGenerativeModel"]>>();
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
// セーフティ設定(本番環境向け)
const safetySettings = [
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
},
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
},
];
export function getGeminiModel(modelName: "gemini-2.5-pro" | "gemini-2.5-flash" | "gemini-2.0-flash-lite") {
if (!clients.has(modelName)) {
clients.set(
modelName,
genAI.getGenerativeModel({
model: modelName,
safetySettings,
})
);
}
return clients.get(modelName)!;
}
// ファイルを Gemini が扱える形式に変換
export function fileToGenerativePart(data: ArrayBuffer, mimeType: string) {
return {
inlineData: {
data: Buffer.from(data).toString("base64"),
mimeType,
},
};
}テキスト + 画像解析の実装(Vision API パターン)
レシート解析 API の構築
実用的な例として、レシート画像から構造化データを抽出する API を実装します。
// src/app/api/analyze-receipt/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getGeminiModel, fileToGenerativePart } from "@/lib/gemini";
// レスポンスのスキーマ定義(Zod で型安全に)
const ReceiptSchema = z.object({
storeName: z.string(),
date: z.string(),
totalAmount: z.number(),
currency: z.string().default("JPY"),
items: z.array(
z.object({
name: z.string(),
quantity: z.number().optional(),
unitPrice: z.number().optional(),
totalPrice: z.number(),
})
),
taxAmount: z.number().optional(),
paymentMethod: z.string().optional(),
});
export type ReceiptData = z.infer<typeof ReceiptSchema>;
const EXTRACTION_PROMPT = `
この画像はレシートです。以下の情報を JSON 形式で正確に抽出してください。
{
"storeName": "店舗名",
"date": "YYYY-MM-DD形式の日付",
"totalAmount": 合計金額(数値のみ),
"currency": "通貨コード(JPY/USD等)",
"items": [
{
"name": "商品名",
"quantity": 数量(不明の場合は省略),
"unitPrice": 単価(不明の場合は省略),
"totalPrice": 小計
}
],
"taxAmount": 税額(不明の場合は省略),
"paymentMethod": "支払い方法(不明の場合は省略)"
}
数値は数字のみで返し、通貨記号は含めないこと。
日付が不明の場合は "unknown" とすること。
`;
export async function POST(req: NextRequest) {
try {
const MAX_SIZE = 10 * 1024 * 1024;
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "ファイルが見つかりません" }, { status: 400 });
}
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "ファイルサイズが 10MB を超えています" },
{ status: 413 }
);
}
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/heic"];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: "JPEG, PNG, WebP, HEIC のみ対応しています" },
{ status: 415 }
);
}
const arrayBuffer = await file.arrayBuffer();
const imagePart = fileToGenerativePart(arrayBuffer, file.type);
const model = getGeminiModel("gemini-2.5-flash");
const result = await model.generateContent([EXTRACTION_PROMPT, imagePart]);
const responseText = result.response.text();
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error("JSON の抽出に失敗しました");
}
const parsed = JSON.parse(jsonMatch[0]);
const validated = ReceiptSchema.parse(parsed);
return NextResponse.json({ success: true, data: validated });
} catch (error) {
console.error("レシート解析エラー:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "データの形式が不正です", details: error.errors },
{ status: 422 }
);
}
return NextResponse.json(
{ error: "解析に失敗しました。もう一度お試しください。" },
{ status: 500 }
);
}
}Antigravity でのデバッグポイント
Antigravity のインラインチャット(⌘I)で以下のプロンプトを使うと効果的です。
このAPIルートのエラーハンドリングを確認し、
Gemini APIのレート制限エラー(429)とネットワークエラーを
適切に処理するリトライロジックを追加してください。
ドキュメント・PDF 解析パイプラインの構築
PDF や Word 文書の解析は、ビジネス向けアプリで特に需要が高い機能です。
文書解析サービスの実装
// src/lib/document-analyzer.ts
import { getGeminiModel, fileToGenerativePart } from "./gemini";
export interface DocumentAnalysisOptions {
extractType: "summary" | "key-points" | "qa" | "structured";
language?: "ja" | "en";
}
export interface DocumentAnalysisResult {
title?: string;
summary?: string;
keyPoints?: string[];
structuredData?: Record<string, unknown>;
processingTimeMs: number;
}
const ANALYSIS_PROMPTS: Record<DocumentAnalysisOptions["extractType"], string> = {
summary: `
このドキュメントを読み、以下を日本語で返してください:
1. タイトルまたは主題(1行)
2. 要約(200文字以内)
3. 主要なポイント(箇条書き3〜5点)
JSON形式:
{"title": "...", "summary": "...", "keyPoints": ["...", "..."]}
`,
"key-points": `
このドキュメントから最も重要な情報を5〜10点、箇条書きで抽出してください。
JSON形式:{"keyPoints": ["...", "..."]}
`,
qa: `
このドキュメントの内容について、想定されるQ&Aを5組生成してください。
JSON形式:{"qa": [{"q": "...", "a": "..."}, ...]}
`,
structured: `
このドキュメントからすべての構造化データ(テーブル、リスト、数値、日付等)を抽出してください。
JSON形式で返してください。
`,
};
export async function analyzeDocument(
fileData: ArrayBuffer,
mimeType: string,
options: DocumentAnalysisOptions
): Promise<DocumentAnalysisResult> {
const startTime = Date.now();
const modelName = mimeType === "application/pdf"
? "gemini-2.5-pro"
: "gemini-2.5-flash";
const model = getGeminiModel(modelName);
const filePart = fileToGenerativePart(fileData, mimeType);
const prompt = ANALYSIS_PROMPTS[options.extractType];
try {
const result = await model.generateContent([prompt, filePart]);
const text = result.response.text();
const jsonMatch = text.match(/\{[\s\S]*\}/);
const data = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
return {
title: data.title,
summary: data.summary,
keyPoints: data.keyPoints,
structuredData: options.extractType === "structured" ? data : undefined,
processingTimeMs: Date.now() - startTime,
};
} catch (error) {
throw new Error(`ドキュメント解析失敗: ${error instanceof Error ? error.message : "不明なエラー"}`);
}
}対応フォーマットと制限
Gemini API が直接扱えるフォーマットは次の通りです。
- 画像: JPEG, PNG, WebP, HEIC/HEIF(最大 20MB)
- 動画: MP4, MOV, AVI, MKV(最大 2GB、ただし File API 経由)
- 音声: WAV, MP3, AAC, OGG, FLAC(最大 20MB)
- 文書: PDF(最大 1000 ページ、100MB)
音声入力と文字起こしの統合
音声ファイルの文字起こし API
// src/app/api/transcribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getGeminiModel, fileToGenerativePart } from "@/lib/gemini";
interface TranscriptionResult {
text: string;
language?: string;
confidence?: "high" | "medium" | "low";
}
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const audioFile = formData.get("audio") as File | null;
const lang = (formData.get("lang") as string) || "auto";
if (!audioFile) {
return NextResponse.json({ error: "音声ファイルが必要です" }, { status: 400 });
}
const allowedAudioTypes = [
"audio/wav", "audio/mpeg", "audio/mp4",
"audio/aac", "audio/ogg", "audio/flac",
];
if (!allowedAudioTypes.includes(audioFile.type)) {
return NextResponse.json(
{ error: "対応していない音声形式です" },
{ status: 415 }
);
}
const arrayBuffer = await audioFile.arrayBuffer();
const audioPart = fileToGenerativePart(arrayBuffer, audioFile.type);
const langInstruction =
lang === "auto"
? "言語を自動検出し、その言語で文字起こしを行ってください。"
: `言語は ${lang} です。`;
const prompt = `
${langInstruction}
この音声ファイルを文字起こしし、以下の JSON 形式で返してください:
{
"text": "文字起こしされたテキスト",
"language": "検出された言語コード(ja/en等)",
"confidence": "high/medium/low(文字起こしの信頼度)"
}
句読点を適切に付け、話者が変わる場合は改行で区切ってください。
聞き取れない部分は [不明瞭] と記載してください。
`;
const model = getGeminiModel("gemini-2.5-flash");
const result = await model.generateContent([prompt, audioPart]);
const text = result.response.text();
const jsonMatch = text.match(/\{[\s\S]*\}/);
const data: TranscriptionResult = jsonMatch
? JSON.parse(jsonMatch[0])
: { text, confidence: "low" };
return NextResponse.json({ success: true, data });
} catch (error) {
console.error("文字起こしエラー:", error);
return NextResponse.json(
{ error: "文字起こしに失敗しました" },
{ status: 500 }
);
}
}マルチモーダルデータを組み合わせた統合パイプライン
実際のユースケースでは、複数のモーダルを組み合わせることで大きな価値が生まれます。たとえば「会議の録音 + スライド画像 + 議事録テンプレート」を組み合わせて、自動で議事録を作成するパイプラインを実装してみましょう。
統合パイプラインの設計
// src/lib/meeting-pipeline.ts
import { GoogleGenerativeAI } from "@google/generative-ai";
import { fileToGenerativePart } from "./gemini";
interface MeetingInput {
audioData: ArrayBuffer;
audioMimeType: string;
slideImages?: Array<{ data: ArrayBuffer; mimeType: string }>;
meetingTitle: string;
attendees?: string[];
}
interface MeetingMinutes {
title: string;
date: string;
attendees: string[];
agenda: string[];
discussions: Array<{
topic: string;
content: string;
decisions: string[];
actionItems: string[];
}>;
nextSteps: string[];
generatedAt: string;
}
export async function generateMeetingMinutes(
input: MeetingInput
): Promise<MeetingMinutes> {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" });
const parts: any[] = [];
parts.push(fileToGenerativePart(input.audioData, input.audioMimeType));
if (input.slideImages?.length) {
for (const slide of input.slideImages) {
parts.push(fileToGenerativePart(slide.data, slide.mimeType));
}
}
const attendeesStr = input.attendees?.join("、") || "参加者不明";
parts.push(`
会議タイトル: ${input.meetingTitle}
参加者: ${attendeesStr}
本日: ${new Date().toISOString().split("T")[0]}
上記の音声録音${input.slideImages?.length ? "とスライド" : ""}を分析し、
以下の JSON 形式で詳細な議事録を作成してください。
{
"title": "会議タイトル",
"date": "YYYY-MM-DD",
"attendees": ["参加者名1", "参加者名2"],
"agenda": ["議題1", "議題2"],
"discussions": [
{
"topic": "議論トピック",
"content": "議論内容の要約(200文字以内)",
"decisions": ["決定事項1", "決定事項2"],
"actionItems": ["アクション: 担当者 → 期限"]
}
],
"nextSteps": ["次のステップ1", "次のステップ2"],
"generatedAt": "ISO 8601 形式"
}
`);
const result = await model.generateContent(parts);
const text = result.response.text();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error("議事録の生成に失敗しました");
}
return JSON.parse(jsonMatch[0]) as MeetingMinutes;
}ストリーミングレスポンスの実装
大きなファイルや長いドキュメントを処理する場合、ストリーミングでユーザー体験を向上させます。
// src/app/api/analyze-stream/route.ts
import { NextRequest } from "next/server";
import { GoogleGenerativeAI } from "@google/generative-ai";
import { fileToGenerativePart } from "@/lib/gemini";
export async function POST(req: NextRequest) {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const formData = await req.formData();
const file = formData.get("file") as File;
const prompt = formData.get("prompt") as string || "このファイルを分析してください";
const arrayBuffer = await file.arrayBuffer();
const filePart = fileToGenerativePart(arrayBuffer, file.type);
const stream = new ReadableStream({
async start(controller) {
try {
const result = await model.generateContentStream([prompt, filePart]);
for await (const chunk of result.stream) {
const text = chunk.text();
if (text) {
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ text })}\n\n`)
);
}
}
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ done: true })}\n\n`)
);
controller.close();
} catch (error) {
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}本番環境でのコスト最適化とパフォーマンスチューニング
マルチモーダル AI を本番環境で運用するにあたり、コスト管理は非常に重要です。
モデル選択戦略
タスクの複雑さに応じてモデルを使い分けることで、API コストを大幅に削減できます。
// src/lib/model-router.ts
type TaskComplexity = "simple" | "standard" | "complex";
interface RoutingDecision {
model: "gemini-2.0-flash-lite" | "gemini-2.5-flash" | "gemini-2.5-pro";
reasoning: string;
}
export function routeToModel(
task: string,
fileType: string,
fileSizeMB: number
): RoutingDecision {
if (fileType === "application/pdf" && fileSizeMB > 5) {
return {
model: "gemini-2.5-pro",
reasoning: "大型 PDF は高精度モデルが必要",
};
}
if (
fileType.startsWith("image/") &&
fileSizeMB < 1 &&
(task.includes("分類") || task.includes("有無") || task.includes("判定"))
) {
return {
model: "gemini-2.0-flash-lite",
reasoning: "単純な分類タスクはコスト最小化",
};
}
return {
model: "gemini-2.5-flash",
reasoning: "標準的なマルチモーダルタスク",
};
}レスポンスキャッシュの実装
同一ファイルへの繰り返しリクエストをキャッシュして API コストを削減します。
// src/lib/analysis-cache.ts
import crypto from "crypto";
const cache = new Map<string, { result: unknown; expiresAt: number }>();
const CACHE_TTL_MS = 60 * 60 * 1000; // 1時間
export function getCacheKey(fileData: ArrayBuffer, prompt: string): string {
const hash = crypto
.createHash("sha256")
.update(Buffer.from(fileData))
.update(prompt)
.digest("hex");
return hash.substring(0, 32);
}
export function getCached<T>(key: string): T | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.result as T;
}
export function setCached<T>(key: string, result: T): void {
cache.set(key, {
result,
expiresAt: Date.now() + CACHE_TTL_MS,
});
}エラーハンドリングとリトライ戦略
// src/lib/retry.ts
interface RetryOptions {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
maxRetries = 3,
initialDelayMs = 1000,
maxDelayMs = 10000,
} = options;
let lastError: Error;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (error instanceof Error) {
if (error.message.includes("INVALID_ARGUMENT") ||
error.message.includes("PERMISSION_DENIED")) {
throw error;
}
}
if (attempt < maxRetries - 1) {
const delay = Math.min(
initialDelayMs * Math.pow(2, attempt) + Math.random() * 1000,
maxDelayMs
);
console.warn(
`リトライ ${attempt + 1}/${maxRetries}: ${delay.toFixed(0)}ms 後に再試行`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError!;
}まとめ
- モデル選択が鍵: タスクの複雑さに応じて Lite・Flash・Pro を使い分けることでコストを最大 70% 削減できる
- スキーマ駆動で安全に: Zod を使った入出力バリデーションで、AI のレスポンスを型安全に扱える
- ストリーミングでUX向上: 大きなファイルの処理はストリーミングレスポンスで待ち時間のストレスを軽減できる
- キャッシュとリトライ: 本番環境では必ずキャッシュとリトライロジックを実装し、安定性とコスト効率を両立する
マルチモーダル AI は 2026 年以降さらに進化が続く分野です。Antigravity の AI エージェント支援と組み合わせることで、個人開発者でも本番品質のアプリを短期間でリリースできます。ぜひ今日から実装を始めてみてください。
Gemini API との連携をさらに深めたい方には、Gemini API カスタム AI ツール実装ガイドとGemini API 高度な統合マスタークラスもあわせてご覧ください。
マルチモーダル AI 開発の技術をさらに体系的に