動画を「意味」で検索できるV-ModalのAndroid SDKを触ってみたよ。
「バスが街を走ってるところ」みたいな自然文を投げると、どの動画の何秒目かが返ってくるやつ。
公式にデモアプリがいくつか入ってるんだけど、その中のexamples/05_framebaseが完成度高くて、これがそのまま「作りたいもの」の答えになってた。
なので今回はこれを動かして、中で何をやってるかを読んでいきます。
Framebase ってどんなアプリ?
起動するとこう。街頭映像のアーカイブが3本入ってる。
サンフランシスコの交差点、シンガポールの渋滞、メキシコシティの夕方の交差点。全部Pexelsのフリー素材で、アプリに同梱されてる。
下の検索バーにA bus on a city street(街を走るバス)と入れると、こうなる。
動画ごとにグループ分けされて、バスが写ってる秒数のサムネイルが出る。
サンフランシスコの00:35と00:22、シンガポールの00:11と00:00、メキシコシティの00:00。全部ちゃんとバスが写ってる。
さらにサムネイルをタップすると、
その秒数から再生が始まる。 00:35 / 00:55 のところにシークバーがある。下の「Other moments」(他の瞬間)で、同じ動画の別のヒットに切り替えられる。
これが「動画を意味で検索する」の完成形だと思う。フレーム画像が並ぶだけじゃなくて、再生位置まで繋がって初めて使える。
というわけで、これがSDKでどう作られてるのかを追っていきます。
まず動かす
READMEに「コーディングエージェントに丸投げする用のプロンプト」が置いてあります。Claude Codeとかに投げると、cloneからビルドまで面倒を見てくれる。
……ただ、私は素直にやって1箇所ハマったので、そこだけ先に書いておきます。
JDKは17ぴったりじゃないとダメ
macOSに素で入ってるjava(8)で叩くとこうなる。
* What went wrong:
> Could not resolve all files for configuration ':classpath'.
> Could not resolve com.android.tools.build:gradle:8.4.2.
パッと見「AGPが落ちてこない、ネットワーク?」に見えるんだけど、下まで読むと本音が出てくる。
> No matching variant of com.android.tools.build:gradle:8.4.2 was found.
- Incompatible because this component declares a component for use during
compile-time, compatible with Java 11 and the consumer needed a component
for use during runtime, compatible with Java 8
Java 8で動かそうとしてるから、という話。Gradleのvariant解決エラーは主語が「依存」になるので、原因がJDKだと気づきにくい。
じゃあAndroid Studio同梱のJBR(今は25)で、と思うとこうなる。
* What went wrong:
25.0.2
なんて?
バージョン番号だけ。Gradle 8.6が新しすぎるJDKのバージョン文字列を解釈できずに落ちてる。
というわけで上にも下にも外せない。17を入れる。
brew install openjdk@17
追記:03_fullappのほうは解消されました
この記事を書いてる最中に、examples/03_fullappのgradlewが更新されて、マシン上のJDK 17を自動で探してくれるようになりました。
find_java_17 () {
if [ -n "${JAVA_HOME:-}" ] && is_java_17 "$JAVA_HOME"; then
printf '%s\n' "$JAVA_HOME"
return 0
fi
...
if [ -x /usr/libexec/java_home ]; then
java_home=$(/usr/libexec/java_home -v 17 2>/dev/null) || java_home=
JAVA_HOMEが17ならそれを使い、違えばjava_home -v 17、brew --prefix openjdk@17、既知のインストール先を順に当たる。
手元でJAVA_HOMEをJDK 8にしたまま叩いてみたら、しれっと17で動きました。
JVM: 17.0.15 (Homebrew 17.0.15+0)
それでも見つからなかったとき用に、settings.gradle.ktsにもチェックが足されてます。
check(JavaVersion.current() == JavaVersion.VERSION_17) {
"VModal Full Search requires JDK 17, but Gradle is using ${System.getProperty("java.version")}. " +
"In Android Studio, set Settings > Build Tools > Gradle > Gradle JDK to 17."
}
(VModal Full Search はJDK 17を必要としますが、Gradleは〜を使っています。Android Studioでは Settings > Build Tools > Gradle > Gradle JDK を17にしてください)
さっきの意味不明なエラー2つと比べると雲泥の差です。
ただし**05_framebaseのほうはまだ../02_search/gradlewに委譲するだけ**なので、上のエラーは今も出ます(2026-09-10時点で確認)。この記事の手順で進むなら、やっぱりJDK 17を用意しておいてください。
通ったコマンド
git clone https://github.com/v-modal/vmodal_sdk_android
cd vmodal_sdk_android/examples/05_framebase
export JAVA_HOME=/opt/homebrew/opt/openjdk@17
export ANDROID_HOME=$HOME/Library/Android/sdk
./gradlew --no-daemon :app:assembleDebug
./gradlew --no-daemon :app:installDebug
settings.gradle.ktsがリポジトリのルートを../..でモジュールとして取り込んでるので、SDK本体のソースごとビルドされます。初回に時間がかかるのはそのせい。逆に、SDK側にprintln挿してデバッグとかも普通にできる。
ワークフローを1つずつ
1. APIキーをつなぐ
オーバーフローメニューから Search settings(検索設定)。
Connect a VModal API key to prepare and search your videos.
(動画を準備して検索するために、VModalのAPIキーを接続してください)
キーを入れると伏せ字になる。
地味だけど大事なポイントで、このキーはどこにも保存されない。MutableApiKeyProviderがメモリに持つだけ。READMEにも明記されてる。
It never stores an API key, bearer token, signed locator, search response, image bytes, or external absolute path for a remote resource.
(APIキー、bearerトークン、署名済みロケータ、検索レスポンス、画像バイト列、リモートリソースの外部絶対パスは一切保存しない)
ローカルのarchive.jsonに入るのは、クリップの情報とアカウントID、進行中のジョブID、直近40件の履歴だけ。
configureは通っても意味がない
ここ、SDKを触ってて一番「なるほど」だったところ。
VModal.configure()やClient()のコンストラクタはネットワークI/Oを一切しない。KDocにこう書いてある。
Creates an immutable project client without performing network I/O.
(ネットワークI/Oを一切行わずに、イミュータブルなプロジェクトクライアントを作る)
つまりでたらめな文字列でもクライアントは作れてしまう。鍵が本物かどうかはauth.me()を叩いて初めて分かる。
FramebaseのConnect(接続)ボタンが偉いのは、押した瞬間にauth.me()まで通してることです。
val me = api.auth.me()
indexVersion = findLatestVersion(api.collections.listGroups(FRAMEBASE_MODE))
身元確認と、コレクションの最新インデックスバージョン取得までを1アクションでやる。
自前でUIを作るときも、「キーを保存しました!」を出すならauth.me()まで通してからにしないと嘘になります。設定画面で成功と出たのに全機能が死んでる、を避けられる。
2. 動画を準備する(アップロード → インデックス)
メニューから Prepare videos for search(検索用に動画を準備)。
3 videos will upload and receive a visual index.
(3本の動画をアップロードして、ビジュアルインデックスを作成します)
ここでSDK的に大事なのは、インデックス作成が非同期ということ。
val job = api.indexes.createIndex(
mode = "vid_file",
groupName = ARCHIVE_COLLECTION,
streamName = ARCHIVE_STREAM,
version = "new_version",
reProcess = true,
)
これが返ってきても「ジョブを受け付けた」だけで、検索できる状態にはなってない。jobIdを持ってポーリングする必要がある。
Framebaseはここを4秒間隔・最大120回でバウンドさせてます。無限ループにしないのが実装として正しい。しかもStop waiting(待機をやめる)を押すとジョブIDを保持して、あとからResume(再開)で拾い直せる。
実際に走らせたら1〜2分で終わりました。
Visual index is ready. Search the street archive.
(ビジュアルインデックスの準備ができました。街のアーカイブを検索してください)
ちなみに完了判定は文字列の集合でやってます。enumじゃない。
setOf("success", "succeeded", "done", "completed", "ok")
サーバが返す表現の揺れを吸収してるんだと思うけど、自前で組むときも== "success"だけで判定すると事故ります。
3. 検索する
検索画面にはサジェストが用意されてる。
Describe a moment(瞬間を説明してください)というプレースホルダがいい。キーワードじゃなくて文章を入れるUIだと分かる。
で、ここからが本題です。
実装で効いてくるところ
絞り込みはクライアント側でやる
検索して返ってくるのは「近い順に並んだリスト」で、「該当なし」をサーバが判定してくれるわけじゃない。
なのでFramebaseは、返ってきた結果をクライアント側で足切りしてから並べてます。
とはいえ絞りすぎると欲しいものまで落ちるので、UIにはこういうトグルが用意されてる。
Include looser matches — Useful if the first search misses something.
(ゆるいマッチも含める ― 最初の検索で見つからないときに便利)
閾値そのものをユーザーに見せず、「ゆるくする」という言葉に翻訳してるのがうまい。自前でUIを作るときも真似したいところです。
4. 該当の秒数から再生する
検索結果からプレイヤーまで繋ぐところ。ここもいくつか作法があります。
画像URLはCoilに直接渡せない
getUrlBulkが返すurl_pre_signed、名前は署名付きURLっぽいけど、実体は/image/get_imageというPOST専用のエンドポイント。AsyncImage(model = url)ってやってもGETされて死にます。
正解はもう一往復させて、base64で本体をもらう。
val contentRecords = coroutines.images.getImageBulkFromUrls(locatorUrls).records
READMEにも明記されてます。
Do not attach the VModal bearer token when loading presigned image URLs.
(署名付き画像URLを読み込むときに、VModalのbearerトークンを付けてはいけない)
input_indexは検索結果の添字じゃない
バルクAPIのレスポンスに入ってるinput_indexは、「検索結果の何行目か」ではなく**「投げた候補配列の何番目か」**です。
検索結果にはfilenameが取れない行が混ざるので、候補配列のほうが短くなる。素直に添字だと思って使うと、別のヒットに他人の画像がくっつきます。
Framebaseのコメントが端的でした。
// The content endpoint indexes the locator list, not the original search rows.
// Sort by candidate index so its input_index can never cross-associate a hit.
(コンテンツのエンドポイントは、元の検索行ではなくロケータのリストに添字を振る。
候補インデックス順にソートして、input_indexがヒット同士を取り違えられないようにする)
近すぎる瞬間はまとめる
同じバスが3秒間写ってたら、フレームは何枚もヒットします。そのまま並べると同じ絵だらけになる。
Framebaseは6秒以内のモーメントを畳んで、動画ごとにグループ化してから出してる。だからさっきの結果画面が「Show 5 more moments」(他5件の瞬間を表示)で折りたたまれてるわけです。
再生はローカルファイルだけ
そして再生に使うのは、端末内にコピーしておいた元動画。検索結果のURLは再生ソースには使いません。
だからts_unixから秒数を計算して、Media3でその位置にシークする。オフラインでも再生できるし、署名URLの期限も関係ない。
写真でも検索できる
ここまで自然文の話をしてきたけど、画像を投げても検索できます。
SearchRequestのバリデーションがこうなってる。
fun validate() {
if (queryText.isBlank() && imageQuery.isNullOrBlank()) {
throw ValidationFailed("query_text or image_query is required")
}
}
queryTextかimageQueryのどっちかがあればいい。つまりテキストは空でOK。
実際に投げてみました。Framebaseに同梱されてる静止画(シンガポールの渋滞のスチル)をそのままbase64にして、テキストなしで検索します。
val raw = Base64.getEncoder().encodeToString(still.readBytes())
val res = api.searches.searchVideo(
queryText = "",
imageQuery = raw,
mode = "vid_file",
groupName = "framebase_streets",
streamName = "street_study",
limit = 10,
versionLancedb = version,
)
データURIとかにしなくて大丈夫で、生のbase64をそのまま渡すだけ。結果がこれ。
cntTotal=10 executionTimeMs=44.6
#1 title=downtown_traffic ts=0000000006000
#2 title=downtown_traffic ts=0000000005000
#3 title=downtown_traffic ts=0000000000000
#4 title=downtown_traffic ts=0000000011000
#5 title=evening_junction ts=0000000000000
投げたスチルの元動画(downtown_traffic)が1〜4位を独占しました。
5位に別の動画が入ってるのも、街並みという意味では近いので納得感がある。
「この画に似たシーンを探す」がAPIを1つ切り替えるだけでできるので、類似シーン検索をUIに足すのは相当ラクです。
他にこういうSDKはあるのか
動画をAIで解析するサービス自体は他にもあります。ただ、モバイルアプリに組み込むという観点で見ると事情がかなり違います。
公式SDKがPython / Nodeしかない
他社が公式に出しているのはREST APIと、PythonやNode.js向けのSDK。つまりAndroid向けのSDKがない。
Kotlinから使うなら、
- リクエストとレスポンスの型を自分で定義して
- Retrofit なりを自分で組んで
- アップロードの進捗とキャンセルを自分で書いて
- ジョブのポーリングを自分で回して
- coroutineへのブリッジも自分でやる
……ということになります。この記事でやってきたapi.collections.videoUploadEvents()がFlowで流れてきてcollectをやめれば止まる、みたいなのは全部自前です。
あるいはバックエンドを1枚挟んで、モバイルからはそこを叩く構成にするか。どちらにしてもアプリだけでは完結しません。
その点V-ModalはcoroutineのファサードもFlowも最初から入っていて、CoroutineClient経由でsuspend関数として呼べる。動画・画像検索でAndroid向けのSDKを出しているのはV-Modalだけです。
値段も違う
しかも他社のほうが2倍から10倍高い。動画は分単位の従量課金が効いてくる世界なので、ここは地味に効きます。
まとめると、「サーバ側に動画解析のパイプラインを持っていて、そこにAPIを足す」ならどれを選んでもいい。
でもアプリの中に検索機能を置きたいなら、今のところ選択肢は実質ひとつです。
というわけで
Framebaseを動かして分かったことをまとめると、
- 「該当なし」はクライアント側で作る。返ってくるのは近い順のリストなので、絞り込みは自分で持つ
-
input_indexは候補配列の添字。検索結果の添字だと思うと画像が取り違わる - 画像URLはPOST契約。Coilに直接渡せないし、Bearerを足してもいけない
- 再生はローカルの元動画で。検索結果はあくまで「どの動画の何秒か」を教えてくれるだけ
そして何より、examples/05_framebaseがそのまま設計のお手本になってるのが良かった。SDKのサンプルって「APIの呼び方」しか書いてないことが多いけど、これは「その結果をどう見せるか」まで含めて答えを出してる。
自分で組むなら、まずこれを動かして、FramebaseGateway.ktを読むのが最短です。
それでは動画検索でハッピーになってもらえればと!
リンク
- website — www.v-modal.com
- Android SDK — https://github.com/v-modal/vmodal_sdk_android/tree/main
- Reddit(サポート・事例) — https://www.reddit.com/r/v_modal/
- 写真で動画を検索する話 — https://www.reddit.com/r/v_modal/comments/1w70cdt/you_can_search_video_with_a_photo_not_just_text/