本記事は PROMPT-X 技術ブログ(blog.prompt-x.jp)に掲載した内容を、業種を問わない形に再構成したものです。
現場の記録は、続きません。紙のノート、専用アプリ、スプレッドシート——どれも「入力する手間」で止まります。手がふさがっている、汚れている、その場でスマホを操作したくない。結果として記録は後回しになり、思い出しながら書くので精度も落ちます。
前に書いた記事では、手書きの帳票を写真に撮ってAIに読ませ、スプレッドシートに構造化して入れるところまでをGASだけで作りました。今回はその次で、入力を動画に広げます。スマホで対象を撮りながら一言喋るだけ。あとはAIが映像と音声の両方を読み取り、決まった列に整えてシートに1行追記します。
Googleアカウントさえあれば追加費用ゼロで動きます。
この記事でやること
- なぜGeminiなのか — 動画をそのままAPIに渡せるモデルは、2026年7月時点でまだ限られる
-
動画をAIに読ませる — GASの
UrlFetchAppでinline_dataに動画を入れて渡す -
構造化してシートに1行 —
responseMimeType: json+temperature: 0、日時はサーバ側で付与 - スマホで撮ってその場で登録 — GASのWebアプリ化と、カメラまわりのつまずきポイント
- 精度をどう見るか — 最安クラス3モデルの実測と、安いモデルの「それっぽい嘘」
なぜGeminiなのか ― 動画をそのまま渡せるAIは、まだ少ない
画像ならどのモデルでも読めるようになりましたが、動画となると一気に選択肢が絞られます。主要3社の本体モデル(API)に、ファイルをそのまま渡せるかを2026年7月15日時点で調べた結果がこれです(各社の公式ドキュメントで確認)。
| モデル(各社最新) | 画像 | 音声 | 動画 |
|---|---|---|---|
| Gemini(Google) | ○ | ○ | ○ |
| GPT-5.5(OpenAI) | ○ | △(別の専用モデルなら可) | ✕ |
| Claude(Anthropic) | ○(PDFも可) | ✕ | ✕ |
Geminiの動画読み取りは、映像(1コマ/秒)と音声を同時に処理します。1時間程度までの動画や、YouTubeのURLをそのまま渡すこともできます。
つまり「撮りながら喋る」という入力は、1本の動画を1回のAPI呼び出しに渡すだけで処理できます。音声認識サービスで文字起こしして、映像は別途フレーム抽出して画像認識にかけて、結果を突き合わせて……という多段構成が要りません。ここが今回Geminiを使う一番の理由です。
準備
GASプロジェクトを作り、Gemini APIキーを発行してスクリプトプロパティに保存します。
- script.google.com で新しいプロジェクトを作成
- Google AI Studio でAPIキーを発行
- GASの「プロジェクトの設定」→「スクリプトプロパティ」に
GEMINI_API_KEYとして保存(コードに直書きしない)
この手順のスクリーンショット付きの説明は、前回の記事にあります。
テスト用に、5〜15秒くらいの動画を1本、撮りながら何か喋ってGoogleドライブに置いてください。題材は何でも構いません。手元の機器を撮って「異音なし、油量は半分くらい」でも、棚を撮って「在庫3箱、来週補充」でも動きます。
動画をAIに読ませる
やることは、画像を渡すときと変わりません。DriveAppで動画を読み、Utilities.base64Encodeしてinline_dataに入れ、プロンプトと一緒にpartsへ渡すだけです。inline_dataの中身が画像から動画に変わる、それだけです。
const GEMINI_MODEL = 'gemini-3.1-flash-lite';
const VIDEO_FILE = 'sample.mp4'; // ドライブに置いた動画のファイル名
// ▶ で実行:映像と音声から読み取った内容がログに出る
function readVideoFromDrive() {
const fileId = findFileId(VIDEO_FILE);
const prompt = 'この動画は、撮影者がその場の様子を撮りながら口頭で説明したものです。'
+ '映像(映っているもの・その状態・写り込んだ文字)と'
+ '音声(ナレーション)の両方を読み取って、わかったことを説明してください。';
Logger.log(askGeminiWithVideo(fileId, prompt));
}
function askGeminiWithVideo(fileId, prompt) {
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
const blob = DriveApp.getFileById(fileId).getBlob();
const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ GEMINI_MODEL + ':generateContent';
const res = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
headers: { 'x-goog-api-key': apiKey },
payload: JSON.stringify({ contents: [{ parts: [
{ text: prompt },
{ inline_data: { mime_type: blob.getContentType(), // mp4 / mov などを自動判定
data: Utilities.base64Encode(blob.getBytes()) } }
] }] }),
muteHttpExceptions: true
});
return JSON.parse(res.getContentText()).candidates[0].content.parts[0].text;
}
// 動画を「ファイル名」で探す(IDを手で貼らなくてよい)
function findFileId(fileName) {
const files = DriveApp.getFilesByName(fileName);
if (!files.hasNext()) throw new Error('ファイルが見つかりません: ' + fileName);
return files.next().getId();
}
実行すると、映っているものの説明と、喋った内容の文字起こしが返ってきます。
inline_dataで埋め込めるのは20MB程度までです。実用上は「15秒くらいまでの動画」が目安になります。長い動画を扱うならFiles APIを使うか、後述のWebアプリ側でサイズを弾いてください。
構造化してシートに1行入れる
このままだと結果は「ひとかたまりの文章」です。集計やグラフ化をしたいなら、決まった列に整える必要があります。generationConfigに次の2つを指定します。
-
responseMimeType: 'application/json'— JSONだけを返させる -
temperature: 0— 毎回同じ抽出結果にする
もうひとつのポイントが、日時をAIに書かせないことです。記録日時はシートに追記するときにGAS側のnew Date()(サーバ時刻)で付けます。利用者は日時を喋らなくてよくなり、記録の信頼性も上がります。AIに任せる必要のないものは、任せないほうが安定します。
// ▶ で実行:動画を読み取り、記録1行としてシートに追記する
function recordVideoToSheet() {
const fileId = findFileId(VIDEO_FILE);
const data = JSON.parse(analyzeVideoAsRecord(fileId));
const rec = data.record || {};
const sh = getRecordSheet().getSheets()[0];
if (sh.getLastRow() === 0) {
sh.appendRow(['記録日時', '天気', '対象', '状態', '数量', '備考', '文字起こし', '映像']);
}
// ★ 記録日時はここで自動付与(AIの出力ではなくサーバ時刻)
const ts = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy-MM-dd HH:mm');
sh.appendRow([
ts,
rec['天気'] || '', rec['対象'] || '', rec['状態'] || '',
rec['数量'] || '', rec['備考'] || '',
data.transcription || '', data.visual || ''
]);
}
// 動画を読み取り、「決まった形のJSON文字列」で返す
function analyzeVideoAsRecord(fileId) {
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
const blob = DriveApp.getFileById(fileId).getBlob();
const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ GEMINI_MODEL + ':generateContent';
const prompt = 'この動画は、記録をつけるために撮影者がその場の様子を撮りながら口頭で述べたものです。'
+ '映像(映っているものの状態・写り込んだ文字)と音声(ナレーション)の両方を読み取ってください。'
+ '日付や時刻は出力に含めないでください(登録時にシステムが自動付与します)。'
+ '出力は次のJSONのみ: '
+ '{"transcription":"音声の文字起こし","visual":"映像から読み取れたこと",'
+ '"record":{"天気":,"対象":,"状態":,"数量":,"備考":}}。該当なしはnull。';
const res = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
headers: { 'x-goog-api-key': apiKey },
payload: JSON.stringify({
contents: [{ parts: [
{ text: prompt },
{ inline_data: { mime_type: blob.getContentType(),
data: Utilities.base64Encode(blob.getBytes()) } }
] }],
generationConfig: {
responseMimeType: 'application/json', // ← 「JSONで返して」
temperature: 0 // ← ぶれない抽出に
}
}),
muteHttpExceptions: true
});
return JSON.parse(res.getContentText()).candidates[0].content.parts[0].text;
}
// 記録用シートを用意する(一度作ったらIDを覚えて使い回す)
function getRecordSheet() {
const p = PropertiesService.getScriptProperties();
const id = p.getProperty('SHEET_ID');
if (id) { try { return SpreadsheetApp.openById(id); } catch (e) {} }
const ss = SpreadsheetApp.create('動画からの記録');
p.setProperty('SHEET_ID', ss.getId());
return ss;
}
列の名前とプロンプトのJSON定義を変えれば、そのまま自分の業務の項目になります。点検記録なら「対象/異常の有無/数値」、棚卸しなら「品名/数量/保管場所」に書き換えるだけです。
なお、より厳密に型まで固定したいならresponseSchemaを使う手もあります。今回はプロンプトで形を伝える方法にしていますが、INTEGER/STRINGまで指定したい場合は前回の記事のほうを参照してください。
スマホで撮って、その場で登録する
「ドライブに置いて▶で実行」では現場で使えません。GASのWebアプリ機能(doGet)で画面を配ると、URLを開くだけでスマホから使えるようになります。
ここで一番のつまずきポイントがカメラの開き方です。
ブラウザでカメラを扱うgetUserMediaは、GASの画面(HtmlServiceのiframe)ではブロックされて動きません。端末やブラウザの問題ではなく、GASでホストしている限り発生します。
回避策は、HTMLの<input type="file" accept="video/*" capture>を使うことです。スマホでタップすると標準のカメラアプリが開き、撮った動画をそのままファイルとして受け取れます。iframeの制約とは無関係に動きます。
もうひとつ入れているのが、登録前のワンクッションです。AIの読み取りは完璧ではないので、シートに書き込む前に人が中身を確認し、「登録する/撮り直す」を選べるようにしています。
function doGet() {
const html = `<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:system-ui;font-size:18px;padding:20px;line-height:1.7">
<h2>動画で記録</h2>
<p>対象を撮りながら、状況を喋ってください(5〜15秒)</p>
<!-- ★ getUserMediaは使わず、capture付きfile inputで標準カメラを開く -->
<input type="file" accept="video/*" capture id="f" style="font-size:18px"><br><br>
<div id="st" style="color:#666"></div>
<div id="preview" style="display:none">
<pre id="out" style="white-space:pre-wrap;background:#f4f4f4;padding:12px;border-radius:8px"></pre>
<p>この内容で登録していいですか?(日時は登録時に自動で記録されます)</p>
<button id="ok">これで登録</button>
<button id="redo">撮り直す</button>
</div>
<div id="done" style="display:none;font-weight:bold"></div>
<script>
var f = document.getElementById('f'), st = document.getElementById('st');
var out = document.getElementById('out'), pv = document.getElementById('preview');
var dn = document.getElementById('done'), pending = null;
// ① 撮る → AIに読ませる → 確認エリアに表示
f.addEventListener('change', function(e){
var file = e.target.files[0]; if (!file) return;
if (file.size > 18 * 1048576) { st.textContent = '動画が大きすぎます。15秒以内で撮り直してください'; return; }
st.textContent = '解析中...';
var r = new FileReader();
r.onload = function(){
var b64 = (r.result.split(',')[1] || '');
google.script.run
.withSuccessHandler(function(res){ pending = res; out.textContent = res; pv.style.display = 'block'; st.textContent = '内容を確認してください'; })
.withFailureHandler(function(err){ st.textContent = 'エラー: ' + err.message; })
.processMedia(b64, file.type || 'video/mp4');
};
r.readAsDataURL(file);
});
// ②「これで登録」→ シートに追記
document.getElementById('ok').addEventListener('click', function(){
if (!pending) return; st.textContent = '登録中...'; pv.style.display = 'none';
google.script.run
.withSuccessHandler(function(url){ dn.innerHTML = '登録しました <a href="' + url + '" target="_blank">シートを開く</a>'; dn.style.display = 'block'; st.textContent = ''; })
.registerRecord(pending);
});
</script></body></html>`;
return HtmlService.createHtmlOutput(html);
}
// 動画(base64)をGeminiに送り、読み取り結果(JSON文字列)を返す
function processMedia(b64, mime) {
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ GEMINI_MODEL + ':generateContent';
const prompt = 'この動画は、記録をつけるために撮影者がその場の様子を撮りながら口頭で述べたものです。'
+ '映像と音声の両方を読み取ってください。日付や時刻は出力に含めないでください。'
+ '出力は次のJSONのみ: {"transcription":"音声の文字起こし","visual":"映像から読み取れたこと",'
+ '"record":{"天気":,"対象":,"状態":,"数量":,"備考":}}。該当なしはnull。';
const res = UrlFetchApp.fetch(url, {
method: 'post', contentType: 'application/json',
headers: { 'x-goog-api-key': apiKey },
payload: JSON.stringify({
contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: mime, data: b64 } }] }],
generationConfig: { responseMimeType: 'application/json', temperature: 0 }
}),
muteHttpExceptions: true
});
return JSON.parse(res.getContentText()).candidates[0].content.parts[0].text;
}
// 確認後に呼ばれる:読み取り結果(JSON)をシートの1行に追記(記録日時は自動付与)
function registerRecord(jsonText) {
const data = JSON.parse(jsonText), rec = data.record || {};
const ss = getRecordSheet();
const sh = ss.getSheets()[0];
if (sh.getLastRow() === 0) {
sh.appendRow(['記録日時', '天気', '対象', '状態', '数量', '備考', '文字起こし', '映像']);
}
const ts = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy-MM-dd HH:mm');
sh.appendRow([ts, rec['天気'] || '', rec['対象'] || '', rec['状態'] || '',
rec['数量'] || '', rec['備考'] || '', data.transcription || '', data.visual || '']);
return ss.getUrl();
}
「デプロイ」→「新しいデプロイ」→種類「ウェブアプリ」で公開し、発行されたURLをスマホで開けば動きます。
ちなみに、この画面のHTML自体もAIに書かせられます。UIデザイン専用AIのGoogle Stitchに「動いているHTML+守るべき契約」を渡して画面を作らせ、実機で動かすまでの検証は別記事にまとめました。
つまずきポイントまとめ
実装していて引っかかったのは次の3点でした。
| 症状 | 原因 | 対処 |
|---|---|---|
| カメラが起動しない |
getUserMediaがHtmlServiceのiframeでブロックされる |
<input type="file" accept="video/*" capture>を使う |
| 大きい動画でエラー |
inline_dataの埋め込み上限(20MB程度) |
ページ側でサイズを弾く(18MBで判定)/15秒以内を案内 |
| 日時がずれる・喋り忘れる | AIに日時まで書かせている | シート追記時にnew Date()で付与する |
なお動画の形式は、webm/opus・mp4/aac・oggのいずれもそのまま受理されました。スマホの標準カメラで撮ったものをそのまま渡して問題ありません。
精度をどう見るか ― 値段でなく実測で選ぶ
「安いモデルで大丈夫なのか」は当然の疑問です。手書きの作業日報1枚を、最安クラスの3モデルに同じ条件で読ませた実測が次のとおりです(2026年7月)。
| モデル | 精度 | 1枚あたり |
|---|---|---|
| Gemini 3.1 Flash-Lite | 100% | $0.0015 |
| GPT-5.4 nano | 47% | $0.0014 |
| GPT-5.4 mini | 94% | $0.0051 |
nanoとほぼ同じ値段で精度は53ポイント差、3倍高いminiにも勝ちました。「高い=正確」とは限りません。
注意したいのは、安いモデルがつく 「それっぽい嘘」 です。読めない箇所を空欄にせず、もっともらしい別の言葉で埋めてきます。実測では「山田太郎」→「よだまち部」、「東町マンション」→「東側マンション」、天気記号の◎(くもり)も○(快晴)も一律「晴れ」と誤読、といった例が出ました。出力のJSONの形は整っているので、パースは通ってしまう。ここが怖いところです。
(モデル比較の詳細は別記事に書きました)
精度を上げる3つのコツ
- プロンプトに「現場の言葉」を教える — 説明なしだと3モデルとも「晴れ/くもり」を「晴れ」止まりでしたが、「『/』=のち、『|』=時々」と一文足すだけで「晴れのちくもり」まで正しく読めました
-
出力の「形」を固定する —
responseMimeType: 'application/json'+temperature: 0 - 人の確認をひと呼吸はさむ — AIは間違える前提で、登録の前に「登録する/撮り直す」を置く
結局のところ、高いモデルに乗り換えるより、プロンプトに現場の言葉を一文足したり確認画面を一個挟んだりするほうが、安上がりで確実に精度が上がりました。
応用アイデア
この仕組みでハンズオンをしたところ、こんな声が出ました。
- 作業手順を撮っておけば、そのままマニュアルに使えるのではないか
- 話すだけで作業日誌がつけられる
- 機器のメンテナンス記録を動画と一緒に残す
特に面白かったのが、 「まず動画で残しておいて、後からAIでデータ化する」 という順番の発想です。記録するその瞬間はスマホで撮るだけにして、構造化は後工程に回す。現場で手がふさがる人ほど、この「撮っておく」の気軽さが効いてきます。動画そのものをアーカイブしておけば、後から抽出したい項目が増えても、プロンプトを変えて読み直せます。
おわりに
作ってみて痛感したのは、モデルの賢さを追いかけるより、記録する側がいかにサボれるかの導線を作ることが、記録を続けさせる鍵だということです。撮って喋るだけ、日時は自動、確認はワンタップ。コードは全部で200行足らずですし、GASもGemini APIも無料枠の範囲で動きます。
「うちの現場ならこう使えそう」というアイデアがあれば、ぜひコメントで教えてください。
なお、こうした仕組みをお客さまの現場の課題に合わせて設計・実装する仕事に興味のある方は、エンジニア採用もご覧ください。




