はじめに
この記事はNTTテクノクロス Advent Calendar 2025 : シリーズ2、15日目の記事です。
こんにちは、NTTテクノクロスの栗原です。
普段はOSSの調査・検証等の業務に携わっています。
今回は業務とは無関係ですが、Firebase AI Logic SDKを使ってGemini APIにYouTubeのレシピ動画をテキスト化させてレシピメモを作るAndroidアプリ作ろう…と思ったのですが、思わぬ壁にぶつかり断念したので、その経緯を書きつつ、Firebase AI Logic SDKの使い方や、使おうと思っていたアイデアなどを紹介できればと思います。
経緯
割と料理が好きで、ほぼ毎日のように自炊しています。レシピは主にYouTubeの動画から見つけています。当然気に入った料理は何度も作るのですが、そのうち大体の手順は頭に入ってくるので、調味料の分量を確認する程度で十分になってきます。ですがそのために動画を再生して、シークバーを動かして…とやって目的の情報を確認する作業が非常に大変で…(動画投稿者の方々には怒られそうですが…)
そこで今話題の生成AIでどうにかレシピ動画をドキュメント化して、ストックできないかと模索していたところ、Gemini Developer APIであれば、
- クレジットカード登録なしで利用できて、無料枠1もそれなりにある
- YouTubeであれば、動画そのものでなくURLを渡すだけで、動画の内容に基づいた回答を生成してくれる
という点で使い勝手が良さそうだったので、Gemini Developer API(以降、特に断りがない限りGemini APIと表記します)を組み込んだAndroidアプリを作ろう!と思ったのですが、この「YouTubeであれば、動画そのものでなくURLを渡すだけで、動画の内容に基づいた回答を生成してくれる」機能が思わぬ障壁になりました。
規約の壁
本記事に記載した規約の解釈は、あくまで個人の見解です。
利用される場合は、必ず専門家や有識者の方に確認してください。
今回は記事を投稿するまでに十分な時間がなかったため、有識者の方に確認をとることができませんでした。そのため今回は利用を控えた方がよいと判断しました。
YouTubeの動画URLを入力として渡す機能はGemini APIの機能の1つとして、公式に提供されています。
ですが、YouTubeの規約の
本サービスの利用には制限があり、以下の行為が禁止されています。
本サービスまたはコンテンツのいずれかの部分に対しても、アクセス、複製、ダウンロード、配信、送信、放送、展示、販売、ライセンス供与、改変、修正、またはその他の方法での使用を行うこと。ただし、(a)本サービスによって明示的に承認されている場合、または(b)YouTube および(適用される場合)各権利所持者が事前に書面で許可している場合を除きます。
という記載について、YouTubeの規約およびGemini APIの利用規約のどこにも「(a)本サービスによって明示的に承認されている場合」に当該機能が該当するという記載を確認できませんでした。また「(b)YouTube および(適用される場合)各権利所持者が事前に書面で許可している場合を除きます。」とあるので、YouTubeだけでなく動画の権利者の許可を得ないと、コンテンツの二次利用もできません。
他にも、当該機能および使い方がYouTubeの規約に抵触していないか判断が難しい部分があり、今回のような使い方は避けるべきという結論に至りました。
また、他者が著作権を持つ動画や文章を要約した結果を再公開すると著作権侵害になりうるので気をつけましょう。
以降は当該機能を除くFirebase AI Logic SDKの使い方、アプリに組み込む予定だった実装のアイデアを紹介できればと思います。
アプリ構成案
ここでは考えていたアプリ構成案を紹介します。
まずYouTubeアプリから共有機能を使って目的のレシピ動画のURL受け取り、それをGemini APIに送ります。Gemini API側はそのURLの動画の内容をテキスト化し、Json形式のレスポンスとして返却します。そして受け取ったレシピをローカルのDBにストックする方式を構想していました。
Firebase AI Logic SDKからGemini APIを使う
AndroidアプリからGemini APIを呼び出すには、Firebase AI Logic SDK を使うのですが、まずはそのための環境構築を行います。
環境構築
Firebaseプロジェクトの設定
Firebase AI Logic SDKを使うには、Firebaseプロジェクトを作成し、そこにアプリを登録する必要があります。
- まず、Firebase コンソールにログインし、任意のプロジェクト名を入力してプロジェクトを作成します。
- コンソール画面左に表示される[プロダクトのカテゴリ] > [AI] > [Firebase AI Logic]を選択します。
- 以下のようなページが表示されたら、[使ってみる]を選択すると、Gemini Developer APIかVertex AI Gemini APIのどちらを使うかを選ぶダイアログが表示されます。今回は無料枠のあるGemini Developer APIを使うので、こちらの[このAPIを使ってみる]を選択します。
- 次にどのプラットフォーム(Android、iOSなど)で使うか聞かれるので、Androidを選択します。その後ワークフローが表示されるので、画面上の指示に沿ってFirebaseに接続するためのセットアップを行います。
SDKの追加
以下の依存関係をモジュール(アプリレベル)の Gradleファイルに追加します。
dependencies {
// Import the BoM for the Firebase platform
implementation(platform("com.google.firebase:firebase-bom:34.6.0"))
// Add the dependency for the Firebase AI Logic library
implementation("com.google.firebase:firebase-ai")
}
Gemini APIとテキストベースのチャットをしてみる
ひとまず環境構築が完了したので、ユーザーが送信したメッセージに対して応答する形でチャットを行うコードを書いてみます。
suspend fun sendChat() {
// モデルの初期化
val generativeModel = Firebase.ai(backend = GenerativeBackend.googleAI())
.generativeModel("gemini-2.5-flash-lite")
// チャットの履歴
val chatHistory = listOf(
// ユーザーからのメッセージ
content("user") {
text("次に送信する単語について解説して")
},
// モデルのレスポンス
content("model") {
text("了解しました。すべての行末に!マークを付けて3行で回答します。")
}
)
// チャット履歴を設定したうえで、モデルにメッセージを送信
val chat = generativeModel.startChat(chatHistory)
val response = chat.sendMessage(
content("user") {
text("Gemini API")
}
)
response.text?.let {
Log.d("sendContent", it)
} ?: kotlin.run {
throw Exception("response.text is null")
}
}
Gemini APIではチャットの履歴(ユーザーとモデルがどのような会話をしていたか)を自由に定義することができ、モデルはユーザーからのメッセージに対して、履歴を踏まえた内容を応答してくれます。上記コードを実行すると、
Gemini APIは、Googleが開発した次世代の大規模言語モデルであるGeminiファミリーにアクセスできるインターフェースです!
開発者はこのAPIを利用して、Geminiの高度な自然言語処理能力を自身のアプリケーションに組み込むことができます!
テキスト生成、翻訳、質疑応答、コード生成など、多岐にわたるタスクで強力なAI機能を実現します!
という履歴を踏まえたレスポンスを返してくれます。
Gemini APIにメッセージの内容をまとめるボットになってもらう
チャットの挙動が分かったところで、次はメッセージの内容をまとめるボットになってもらいます。この場合、毎回チャットに指示を書くのは非効率なので、システム指示を使って役割や出力形式を定義してみます。プロンプトはプロンプト設計戦略 | Gemini API | Google AI for Developersを参考にしています。
suspend fun sendChat() {
// モデルの初期化
val generativeModel = Firebase.ai(backend = GenerativeBackend.googleAI())
.generativeModel(
modelName = "gemini-2.5-flash-lite",
// システム指示を定義
systemInstruction = content {
text(
"""
<role>
あなたはレシピ情報を抽出するためのアシスタントです。
</role>
<task>
1. メッセージの内容を分析し、レシピの数と区切りを特定する。
2. 各レシピについて、constraintsを遵守しつつ、「レシピ名/材料/調味料/手順」の4項目を正確に抽出する。
3. output_formatに従い、整形して出力する。
</task>
<constraints>
材料と調味料の単位はレシピに登場する表現の通り、正確に記載すること。
文章中に複数の料理のレシピが出てくる場合はそれぞれの料理ごとに「レシピ名/材料/調味料/手順」の項目を整理すること。
</constraints>
<output_format>
材料と調味料は箇条書きで「名前:量」表記で記載すること。
</output_format>
"""
)
}
)
val chat = generativeModel.startChat()
// YouTubeの動画のURLを送信
val response = chat.sendMessage(
// 架空のレシピを渡してみる
content("user") {
text(
"""
<虹色雲海スープ>
・魔法使いの鍋に夜明けのしずく(200ml)を入れ、炎の精の力で弱火にかける。決して沸騰させない(約70℃を保つ)。
・温まったしずく*精霊の涙(5滴)を加え、木製のスプーンでゆっくりと3回、時計回りに混ぜて甘味を均一にする。
・夢見草の花びら(5枚)ときらめき苔(小さじ1、細かく刻んだもの)をそっと投入する。苔が鮮やかな虹色に輝き始めるまで、目を離さずに静かに待つ(約4分間)。
・火から下ろし、透明な器に静かに注ぎ入れる。
・最後に空気の実(3粒)を飾り、食べる直前に軽くフォークで潰す。
・仕上げに星屑のスパイス(ひとつまみ)を散らす
"""
)
}
)
response.text?.let {
Log.d("sendContent", it)
} ?: kotlin.run {
throw Exception("response.text is null")
}
}
入力にGemini APIに別途考えてもらった架空のレシピを渡して実行してみます。するとGemini APIで以下のようにシステム指示に定義した役割や制約に則ったレスポンスを返してくれます。
レシピは1つです。
**レシピ名**
虹色雲海スープ
**材料**
* 夜明けのしずく:200ml
* 夢見草の花びら:5枚
* きらめき苔(細かく刻んだもの):小さじ1
* 空気の実:3粒
**調味料**
* 精霊の涙:5滴
* 星屑のスパイス:ひとつまみ
**手順**
1. 魔法使いの鍋に夜明けのしずく(200ml)を入れ、炎の精の力で弱火にかける(決して沸騰させない、約70℃を保つ)。
2. 温まったしずくに精霊の涙(5滴)を加え、木製のスプーンでゆっくりと3回、時計回りに混ぜて甘味を均一にする。
3. 夢見草の花びら(5枚)ときらめき苔(小さじ1、細かく刻んだもの)をそっと投入する。苔が鮮やかな虹色に輝き始めるまで、目を離さずに静かに待つ(約4分間)。
4. 火から下ろし、透明な器に静かに注ぎ入れる。
5. 最後に空気の実(3粒)を飾り、食べる直前に軽くフォークで潰す。
6. 仕上げに星屑のスパイス(ひとつまみ)を散らす。
内容はともかく、定義した制約や出力形式に則って整理してくれているのが分かると思います。
Gemini APIのレスポンスをJson形式に制約する
ここまででやろうとしていたことの9割はできました。ですが入力がどんなものであれ、画面に表示すること、最終的にDBに保存することを考えたとき、現状のレスポンス形式では後処理が非常に面倒です。そこで、Gemini APIの機能の1つである「構造化出力」を利用します。
構造化出力とは?
指定したスキーマに準拠したレスポンスを生成するように、Geminiモデルを設定する機能です。Jsonスキーマを指定することでレスポンスをJson形式に制約したり、列挙型を使用することで、特定の値のリストから選択してレスポンスを返すように制約できます。
今回は以下のような構造のJsonの配列をレスポンスとして返すように設定したいと思います。
[
{
"レシピ名": "レシピ1の名前",
"材料": [
"名前: 量",
...
],
"調味料": [
"名前: 量",
...
],
"手順": [
"手順1",
...
]
},
...
]
先ほどのコードに以下のようなgenerationConfigを追記し、レスポンススキーマに関する設定を追記します。
suspend fun sendChat() {
val generativeModel = Firebase.ai(backend = GenerativeBackend.googleAI())
.generativeModel(
modelName = "gemini-2.5-flash-lite",
systemInstruction = content {
text(
"""
<role>
あなたはレシピ情報を抽出するためのアシスタントです。
</role>
<task>
1. メッセージの内容を分析し、レシピの数と区切りを特定する。
2. 各レシピについて、constraintsを遵守しつつ、「レシピ名/材料/調味料/手順」の4項目を正確に抽出する。
3. output_formatに従い、整形して出力する。
</task>
<constraints>
材料と調味料の単位はレシピに登場する表現の通り、正確に記載すること。
文章中に複数の料理のレシピが出てくる場合はそれぞれの料理ごとに「レシピ名/材料/調味料/手順」の項目を整理すること。
</constraints>
<output_format>
材料と調味料は箇条書きで「名前:量」表記で記載すること。
</output_format>
"""
)
},
// レスポンススキーマを定義
+ generationConfig = generationConfig {
+ responseMimeType = "application/json" // レスポンスのデータの種類(ここではレスポンスをJson形式に設定したいので、application/jsonを設定)
+ // 動画から抽出したレシピ情報(レシピ名、材料、調味料、手順)を配列で返す
+ responseSchema = Schema.array(
+ Schema.obj(
+ properties = mapOf(
+ // レシピ名(String型)
+ "レシピ名" to Schema.string(
+ title = "レシピ名",
+ description = "レシピの名前"
+ ),
+ // 材料(String型の配列)
+ "材料" to Schema.array(
+ title = "材料リスト",
+ description = "材料のリスト",
+ items = Schema.string(
+ title = "材料名",
+ description = "材料の量"
+ )
+ ),
+ // 調味料(String型の配列)
+ "調味料" to Schema.array(
+ title = "調味料リスト",
+ description = "調味料のリスト",
+ items = Schema.string(
+ title = "調味料名",
+ description = "調味料の量"
+ )
+ ),
+ // 手順(String型の配列)
+ "手順" to Schema.array(
+ title = "手順",
+ description = "手順の番号付きリスト",
+ items = Schema.string(
+ title = "手順名",
+ description = "手順の内容"
+ )
+ )
+ )
+ )
+ )
+ }
)
val chat = generativeModel.startChat()
val response = chat.sendMessage(
content("user") {
text(
"""
<虹色雲海スープ>
・魔法使いの鍋に夜明けのしずく(200ml)を入れ、炎の精の力で弱火にかける。決して沸騰させない(約70℃を保つ)。
・温まったしずく*精霊の涙(5滴)を加え、木製のスプーンでゆっくりと3回、時計回りに混ぜて甘味を均一にする。
・夢見草の花びら(5枚)ときらめき苔(小さじ1、細かく刻んだもの)をそっと投入する。苔が鮮やかな虹色に輝き始めるまで、目を離さずに静かに待つ(約4分間)。
・火から下ろし、透明な器に静かに注ぎ入れる。
・最後に空気の実(3粒)を飾り、食べる直前に軽くフォークで潰す。
・仕上げに星屑のスパイス(ひとつまみ)を散らす
"""
)
}
)
response.text?.let {
Log.d("sendContent", it)
} ?: kotlin.run {
throw Exception("response.text is null")
}
}
改めて上記コードを実行すると、
[
{
"レシピ名": "虹色雲海スープ",
"材料": [
"夜明けのしずく: 200ml",
"夢見草の花びら: 5枚",
"きらめき苔(細かく刻んだもの): 小さじ1",
"空気の実: 3粒"
],
"調味料": [
"精霊の涙: 5滴",
"星屑のスパイス: ひとつまみ"
],
"手順": [
"魔法使いの鍋に夜明けのしずく(200ml)を入れ、炎の精の力で弱火にかける(決して沸騰させない、約70℃を保つ)。",
"温まったしずくに精霊の涙(5滴)を加え、木製のスプーンでゆっくりと3回、時計回りに混ぜて甘味を均一にする。",
"夢見草の花びら(5枚)ときらめき苔(小さじ1、細かく刻んだもの)をそっと投入する。苔が鮮やかな虹色に輝き始めるまで、目を離さずに静かに待つ(約4分間)。",
"火から下ろし、透明な器に静かに注ぎ入れる。",
"最後に空気の実(3粒)を飾り、食べる直前に軽くフォークで潰す。",
"仕上げに星屑のスパイス(ひとつまみ)を散らす。"
]
}
]
というレスポンスが返ってきます。材料と調味料の分類はこれで妥当なのだろうかという疑問はさておき、このように欲しい構造のJson形式のレスポンスを取得できました。
実装のアイデア
ここからは上述のアプリ構成案を実現するなら、どのように実装するか考えていたことを書いていきます。
もし実現するならワンボタンでテキスト化できるようにしたかったので、
- YouTubeアプリ(他のアプリ)から、自作アプリに対して 動画URL(テキストデータ)を共有する
- 動画URL(テキストデータ)が共有された場合のみ、専用画面を表示する
- 専用画面が表示されたことをトリガーに、共有された動画URLをGemini APIに送り、レスポンスをDBに保存する
という仕組みを考えていました。UIはJetpack Composeを使用して実装予定だったのですが、1.と2.を実現する方法がいまいち分からず、いろいろ調べていました。
せっかくなので、UI部分だけは勉強ついでに実装してみたので、それを例に紹介します。
実装するにあたり、ComposeのNavigationを使ってShareTargetアプリ(他アプリから共有情報を受信するアプリ)にするを参考にさせていただきました。
YouTubeアプリ(他のアプリ)から、自作アプリに対して 動画URL(テキストデータ)を共有する
まずインテントフィルタ2を設定します。
インテントフィルタは、アプリが受け取ることのできるインテント3を定義するもので、設定しないと「共有する」や「地図アプリで開く」などの操作を選択したとき、どのアプリで実行するかの選択肢にアプリが表示されません。
今回は「テキストデータの共有」を実行するインテントだけ許可したいので、以下のように設定しています。
<?xml version="1.0" encoding="utf-8"?>
<manifest ... >
<application ...>
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.CockDoc" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- このアプリが受け取ることのできるインテントを定義する -->
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" /> <!-- 許可するインテントのアクション -->
+ <category android:name="android.intent.category.DEFAULT" /> <!-- 暗黙的インテントの受信を許可する -->
+ <data android:mimeType="text/*" /> <!-- 許可するデータの種類 -->
+ </intent-filter>
</activity>
</application>
</manifest>
ここまで設定すると、YouTubeアプリ(他のアプリ)で「共有する」を選択したときに、共有先として自作アプリが選択肢に表示されるようになります。
動画URL(テキストデータ)が共有された場合のみ、専用画面を表示する
画面遷移の実装はNavigationコンポーネントで行います。
今回は、
- アプリを通常通り起動したとき
- 他のアプリから動画URL(テキストデータ)が共有されたとき
で表示する画面を変えたいので、およそ以下のような実装にしています。
@Composable
fun ScreenNavigation(
navController: NavHostController,
padding: PaddingValues
) {
NavHost(
navController = navController,
startDestination = NavigationItem.Menu().route,
modifier = Modifier.padding(padding)
) {
NavigationItem.ShareTarget().run {
composable(route,
// どのような内容のインテントを受信したときに、このComposable関数に遷移するのかを定義します。
// 例えば以下のように定義することで、他のアプリからテキストデータが共有されたとき、このComposable関数に遷移します。
deepLinks = listOf(
navDeepLink {
action = intentAction // 受け入れるインテントのアクション(ここではデータを送信するACTION_SENDを設定)
mimeType = intentMimeType // 受け入れるデータの種類(ここではテキストデータであるtext/*を設定)
}
)
) {
// YouTubeアプリ(他のアプリ)から動画URL(テキストデータ)が共有されたときに表示する画面
SharedScreen()
}
}
composable(NavigationItem.Menu().route) {
// アプリを通常通り起動したときに表示する画面
MenuScreen()
}
}
}
// 画面遷移先を表すクラス
sealed interface NavigationItem {
data class ShareTarget(val route: String = "shareTarget",
val intentAction: String = Intent.ACTION_SEND,
val intentMimeType: String = "text/*")
data class Menu(val route: String = "menu",
val icon: ImageVector = Icons.Default.Menu,
val title: String = "menu")
}
これで特定のインテントを受信した場合のみ、専用の画面に遷移する処理を実現できます。
共有された動画URL(テキストデータ)を取得する
最後に受信したインテントからデータを取得する方法も簡単に紹介します。
今回の実装では、ViewModel上でsavedStateHandleからインテントを取得し、そこから共有されたテキストデータ(動画URL)を取得しています。
// 共有されたときに表示する画面(上述のコードでいうGenerationScreen())の状態を保持するViewModel
@HiltViewModel
class SharedViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private var sharedYoutubeURL = ""
// 共有されたデータを取得する関数
private suspend fun getSharedContent() {
// SavedStateHandleはViewModelのデータのkey-value形式で保持するクラスで、これからNavController.KEY_DEEP_LINK_INTENTをKeyとして、インテントを取得できます。
val intent = savedStateHandle.getStateFlow(NavController.KEY_DEEP_LINK_INTENT, Intent()).first()
// インテントからYouTubeのURLを取得
when (val result = getSharedYoutubeUrl(intent)) {
/**
* Either型について
* Eitherは「成功」か「失敗」のどちらかの値を持つ型でRightが成功(値)、Leftが失敗(例外など)を表現します。以下のような感じでエラーハンドリングに使用します。
* 参考:https://apidocs.arrow-kt.io/arrow-core/arrow.core/-either/index.html
*/
is Either.Right -> {
sharedYoutubeURL = result.value
}
is Either.Left -> {
throw result.value
}
}
}
// インテントから動画URL(テキストデータ)を取得する関数
private fun getSharedYoutubeUrl(intent: Intent): Either<IllegalArgumentException, String> {
// インテントのアクションがACTION_SENDかどうか確認
if (intent.action != Intent.ACTION_SEND) {
return IllegalArgumentException("${intent.action} is not ACTION_SEND").left()
}
// 受け取るデータがテキストデータかどうか確認
if (intent.type?.startsWith("text/") == true) {
// インテントからテキスト(動画URL)を取得
val sharedUrl = intent.getStringExtra(Intent.EXTRA_TEXT)
?: return IllegalArgumentException("text is null").left()
return sharedUrl.right()
}
return IllegalArgumentException("${intent.type} is not text").left()
}
}
さいごに
本記事では、Firebase AI Logic SDKを使ってGemini APIにYouTubeのレシピ動画をテキスト化させてレシピメモを作るAndroidアプリ…ではなくそのアイデアを紹介しました。
今回は「こんなアプリがあればいいな」で見切り発車で制作を始めてしまいましたが、事前に規約をよく確認しておくべきだったなと反省しています。規約関係の話は実際の業務でも直面する可能性のあることだと思うので、非常に勉強になりました。
SDK自体は使い勝手がよく、生成AIを組み込んだアプリを作るのに便利だなという印象を受けました。Google公式からGitHub - android/ai-samplesにFirebase AI Logic SDKを使った様々なサンプルアプリが公開されているので、興味のある方は一度使ってみてはいかがでしょうか。(規約に抵触しない範囲で)
最後までお読みいただき、ありがとうございました。
NTTテクノクロス Advent Calendar 2025:シリーズ2、明日は @y-minori さんの記事です!ぜひご覧ください!
