はじめに
Googleが2024年5月に発表したGemini 1.5 Flashでは、100万トークンを扱えたり、1時間の動画を扱えるとのことです。(参考)
1時間の動画を扱えるのであれば会議の録画動画から議事録作成を自動化できるのではないかと考えたので、Google Apps ScriptでちょっとしたWebアプリを作ってみたいと思います。
Webアプリの仕様
ユーザーが自由に動画を選択し、プロンプトもテンプレートは用意しますが自由に設定できるようにします。Bootstrap5で見た目を整え、以下のような画面を作ります。
使い勝手を考え、使用したプロンプトはテンプレートとして保存できるような機能も付けておきました。また、temparatureや動画の開始時間・終了時間も設定可能とします。
事前準備
動画の保存先としてCloud Storageを使用するので、バケットを作成しておいてください。また、今回はサービスアカウントを使用してプログラムを実行するので、Cloud StorageとVertex AIを使用できるサービスアカウントを作成してください。
実装内容
今回はスプレッドシートのコンテナバインド型のGASとして実装します。スプレッドシートはプロンプトの保存用として使用しています。
作成するファイルは以下の5つです。Webアプリの画面を構成するhtmlファイルは、役割に応じてファイルを分割しています。
- webApp.gs
- index.html
- javaScript.html
- style.html
- main.gs
また、サービスアカウントの情報とCloud Storageのバケット名はGASのスクリプトプロパティに以下の名前で登録してください。
- SERVICE_ACCOUNT_EMAIL
- PRIVATE_KEY
- BUCKET_NAME
webApp.gs
まずはフロントエンド側の仕組みから説明します。
function doGet() {
const scriptProperties = PropertiesService.getScriptProperties();
const serviceAccountEmail = scriptProperties.getProperty('SERVICE_ACCOUNT_EMAIL');
const privateKey = scriptProperties.getProperty('PRIVATE_KEY');
const bucketName = scriptProperties.getProperty('BUCKET_NAME');
const templates = getPromptTemplates();
const template = HtmlService.createTemplateFromFile('index');
template.serviceAccountEmail = serviceAccountEmail;
template.privateKey = privateKey;
template.bucketName = bucketName;
template.templates = templates;
return template.evaluate();
}
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename)
.getContent();
}
function getPromptTemplates() {
const sheet = SpreadsheetApp.getActive().getSheetByName('template');
const data = sheet.getRange('A1:B').getValues();
const templates = data.slice(1)
.filter(row => row[0] && row[1])
.map(row => ({ name: row[0], prompt: row[1] }));
return templates;
}
doGet関数はWebアプリケーションにGETアクセスがあった時に実行される関数です。スクリプトプロパティに登録した情報と、スプレッドシートに記載されているプロンプトのテンプレートを取得してindex.htmlに渡すという処理をしています。
getPromptTemplates関数はスプレッドシートからプロンプトのテンプレートを取得しています。スプレッドシートは以下のような形式です。
include関数については、次に説明するindex.htmlで使用している関数になります。CSS,JavaScript用で分割したファイルを取り込むための関数です。
index.html
<!DOCTYPE html>
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<?!= include('style'); ?>
</head>
<body>
<div class="container mt-5">
<h1 class="text-center">議事録自動作成</h1>
<div class="mb-3">
<label for="fileInput" class="form-label">動画ファイルを選択:</label>
<input type="file" class="form-control" id="fileInput" name="fileInput" required>
</div>
<div class="mb-3">
<label for="templateSelect" class="form-label">テンプレートを選択:</label>
<select class="form-select" id="templateSelect" onchange="updatePrompt()">
<option value="">テンプレートを選択してください</option>
<? for (var i = 0; i < templates.length; i++) { ?>
<option value="<?= templates[i].prompt ?>"><?= templates[i].name ?></option>
<? } ?>
</select>
</div>
<div class="mb-3">
<label for="promptInput" class="form-label">プロンプトを編集:</label>
<textarea class="form-control" id="promptInput" rows="10"></textarea>
</div>
<div class="mb-3">
<details>
<summary>詳細設定</summary>
<div class="row mb-3 mt-2">
<div class="col">
<label for="startTime" class="form-label">開始時間 (分:秒):</label>
<input type="text" class="form-control" id="startTime" name="startTime" placeholder="例: 1:30">
</div>
<div class="col">
<label for="endTime" class="form-label">終了時間 (分:秒):</label>
<input type="text" class="form-control" id="endTime" name="endTime" placeholder="例: 3:45">
</div>
</div>
<div class="mb-3">
<label for="temperature" class="form-label">Temperature (0.0 - 2.0):</label>
<span id="temperatureValue">1.0</span>
<div class="range-container">
<input type="range" class="form-range" id="temperature" name="temperature" min="0" max="2" step="0.1" value="1" oninput="updateTemperatureLabel(this.value)">
<div class="range-track" id="rangeTrack"></div>
</div>
</div>
</details>
</div>
<form id="uploadForm" class="mt-4">
<input type="hidden" id="fileName" name="fileName">
<button type="button" class="btn btn-primary" onclick="createSummary()">議事録作成</button>
<button type="button" class="btn btn-secondary" onclick="showSaveTemplateModal()">テンプレートを保存</button>
</form>
<button id="copyButton" class="btn btn-secondary mt-3 mb-3" onclick="copyToClipboard()" style="display: none;">クリップボードにコピー</button>
<div id="summary" class="mt-3"></div>
<div id="loading" class="loading-overlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Uploading file, please wait...</p>
<div class="progress" id="progressBar">
<div class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
</div>
</div>
<div id="loading-summary" class="loading-overlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>議事録を作成中です。お待ちください...</p>
</div>
</div>
<!-- モーダルの追加 -->
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="saveTemplateModalLabel">テンプレートを保存</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="templateNameInput" class="form-label">テンプレート名:</label>
<input type="text" class="form-control" id="templateNameInput">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">キャンセル</button>
<button type="button" class="btn btn-primary" onclick="saveTemplate()">保存</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const serviceAccountEmail = '<?= serviceAccountEmail ?>';
const privateKey = '<?= privateKey ?>';
const bucketName = '<?= bucketName ?>';
</script>
<?!= include('javaScript'); ?>
</body>
</html>
「Webアプリの仕様」でお見せした以下のような画面が作成されます。Bootstrap5を使用して見た目は整えています。
また、処理中のローディング画面をファイルアップロード時と議事録作成時の2つ作っています。
javaScript.html
少し長いので、全コードは以下を展開してご確認ください。
javaScript.html全文
<script>
let markdownText = '';
document.getElementById('fileInput').addEventListener('change', uploadFile);
function updateTemperatureLabel(value) {
document.getElementById('temperatureValue').innerText = parseFloat(value).toFixed(1);
document.getElementById('rangeTrack').style.width = `${(value / 2) * 100}%`;
}
document.addEventListener('DOMContentLoaded', (event) => {
updateTemperatureLabel(document.getElementById('temperature').value);
});
function updatePrompt() {
const templateSelect = document.getElementById('templateSelect');
const promptInput = document.getElementById('promptInput');
promptInput.value = templateSelect.value;
}
async function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert('No file selected.');
return;
}
document.getElementById('loading').style.display = 'block';
const url = 'https://storage.googleapis.com/upload/storage/v1/b/' + bucketName + '/o?uploadType=media&name=' + encodeURIComponent(file.name);
const token = await getAccessToken();
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
const progressBar = document.querySelector('.progress-bar');
progressBar.style.width = percentComplete + '%';
progressBar.setAttribute('aria-valuenow', percentComplete);
progressBar.textContent = Math.round(percentComplete) + '%';
}
};
xhr.onload = function() {
document.getElementById('loading').style.display = 'none';
resetProgressBar();
};
xhr.onerror = function() {
document.getElementById('loading').style.display = 'none';
resetProgressBar();
alert('Error uploading file.');
};
xhr.send(file);
const fileName = fileInput.files[0].name;
document.getElementById('fileName').value = fileName;
}
function resetProgressBar() {
const progressBar = document.querySelector('.progress-bar');
progressBar.style.width = '0%';
progressBar.setAttribute('aria-valuenow', 0);
progressBar.textContent = '0%';
}
function createSummary() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
const promptInput = document.getElementById('promptInput').value;
const startTime = document.getElementById('startTime').value || null;
const endTime = document.getElementById('endTime').value || null;
const temperature = parseFloat(document.getElementById('temperature').value).toFixed(1);
if (!file) {
alert('No file selected.');
return;
}
const fileName = file.name;
document.getElementById('loading-summary').style.display = 'block';
google.script.run.withSuccessHandler(onSuccess).withFailureHandler(onFailure).summarizeVideo(fileName, promptInput, startTime, endTime, temperature);
}
function onSuccess(response) {
document.getElementById('loading-summary').style.display = 'none';
markdownText = response.summary; // Markdownテキストを保持
const summaryElement = document.getElementById('summary');
summaryElement.innerHTML = marked.parse(markdownText);
document.getElementById('copyButton').style.display = 'block';
}
function onFailure(error) {
document.getElementById('loading-summary').style.display = 'none';
alert("処理に失敗しました: " + error.message);
}
function showSaveTemplateModal() {
const saveTemplateModal = new bootstrap.Modal(document.getElementById('saveTemplateModal'));
saveTemplateModal.show();
}
function saveTemplate() {
const templateName = document.getElementById('templateNameInput').value;
if (!templateName) {
alert('テンプレート名が必要です。');
return;
}
const promptInput = document.getElementById('promptInput').value;
google.script.run.withSuccessHandler(onSaveSuccess).withFailureHandler(onSaveFailure).savePromptTemplate(templateName, promptInput);
const saveTemplateModal = bootstrap.Modal.getInstance(document.getElementById('saveTemplateModal'));
saveTemplateModal.hide();
}
function onSaveSuccess(message) {
showToast(message, document.querySelector('button.btn-secondary'));
}
function onSaveFailure(error) {
alert('テンプレートの保存に失敗しました: ' + error.message);
}
function copyToClipboard() {
const copyButton = document.getElementById('copyButton');
navigator.clipboard.writeText(markdownText).then(() => {
showToast('クリップボードにコピーしました。', copyButton);
}).catch(err => {
showToast('クリップボードへのコピーに失敗しました: ' + err, copyButton);
});
}
function showToast(message, button) {
const toastElement = document.createElement('div');
toastElement.className = 'toast align-items-center text-white bg-secondary border-0';
toastElement.style.position = 'absolute';
toastElement.style.top = `${button.offsetTop}px`;
toastElement.style.left = `${button.offsetLeft + button.offsetWidth + 10}px`;
toastElement.style.zIndex = 1050;
toastElement.style.backgroundColor = 'rgba(108, 117, 125, 0.8)'; // 薄めの背景色
toastElement.style.fontSize = '0.875rem'; // 小さめのフォントサイズ
toastElement.style.padding = '0.5rem 1rem'; // 小さめのパディング
toastElement.style.borderRadius = '0.25rem'; // 角を少し丸める
toastElement.role = 'alert';
toastElement.ariaLive = 'assertive';
toastElement.ariaAtomic = 'true';
const toastBody = document.createElement('div');
toastBody.className = 'toast-body';
toastBody.textContent = message;
toastElement.appendChild(toastBody);
document.body.appendChild(toastElement);
const toast = new bootstrap.Toast(toastElement, { delay: 1500 });
toast.show();
toastElement.addEventListener('hidden.bs.toast', () => {
document.body.removeChild(toastElement);
});
}
async function getAccessToken() {
try {
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: serviceAccountEmail,
sub: serviceAccountEmail,
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
scope: 'https://www.googleapis.com/auth/cloud-platform'
};
const header = {
alg: 'RS256',
typ: 'JWT'
};
const base64UrlEncode = (obj) => {
return btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
};
const signatureInput = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
let key;
try {
key = await crypto.subtle.importKey(
'pkcs8',
str2ab(privateKey),
{
name: 'RSASSA-PKCS1-v1_5',
hash: { name: 'SHA-256' }
},
false,
['sign']
);
} catch (importKeyError) {
console.error('Error importing key:', importKeyError);
throw importKeyError;
}
let signature;
try {
signature = await crypto.subtle.sign(
{
name: 'RSASSA-PKCS1-v1_5',
hash: { name: 'SHA-256' }
},
key,
new TextEncoder().encode(signatureInput)
);
} catch (signError) {
console.error('Error signing JWT:', signError);
throw signError;
}
const jwt = signatureInput + '.' + btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
let response;
try {
response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' + jwt
});
} catch (fetchError) {
console.error('Error fetching access token:', fetchError);
throw fetchError;
}
if (!response.ok) {
throw new Error('Failed to fetch access token: ' + response.statusText);
}
const data = await response.json();
return data.access_token;
} catch (error) {
console.error('Error in getAccessToken:', error);
throw error;
}
}
function str2ab(pem) {
const pemHeader = "-----BEGIN PRIVATE KEY-----";
const pemFooter = "-----END PRIVATE KEY-----";
const pemContents = pem.replace(pemHeader, '').replace(pemFooter, '').replace(/\\n/g, '');
const base64String = pemContents;
const binary_string = window.atob(base64String);
const len = binary_string.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary_string.charCodeAt(i);
}
return bytes.buffer;
}
</script>
いくつかピックアップして解説します。
まず前提として、1時間の動画ファイルをプロンプトと一緒に渡すと容量制限に引っ掛かるため、Cloud StorageにアップロードしてそのURIを入力として使用しています。今回はJavaScriptでCloud Storageへのアップロードを実現しました。GASのサーバーサイド側で動画ファイルをアップロードをしようと思うとGASの制限に引っ掛かる可能性があるので、このような形としています。
該当部分は別記事で書いておりますので、そちらをご確認ください。以下の記事と今回の違いとしては、今回はファイル選択時にアップロードを実施するようにしています。後の議事録作成時にCloud StorageのURIを使用したいので、このタイミングでアップロードさせました。
以下の部分が議事録作成ボタンを押した時の処理です。
function createSummary() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
const promptInput = document.getElementById('promptInput').value;
const startTime = document.getElementById('startTime').value || null;
const endTime = document.getElementById('endTime').value || null;
const temperature = parseFloat(document.getElementById('temperature').value).toFixed(1);
if (!file) {
alert('No file selected.');
return;
}
const fileName = file.name;
document.getElementById('loading-summary').style.display = 'block';
google.script.run.withSuccessHandler(onSuccess).withFailureHandler(onFailure).summarizeVideo(fileName, promptInput, startTime, endTime, temperature);
}
function onSuccess(response) {
document.getElementById('loading-summary').style.display = 'none';
markdownText = response.summary; // Markdownテキストを保持
const summaryElement = document.getElementById('summary');
summaryElement.innerHTML = marked.parse(markdownText);
document.getElementById('copyButton').style.display = 'block';
}
function onFailure(error) {
document.getElementById('loading-summary').style.display = 'none';
alert("処理に失敗しました: " + error.message);
}
htmlからファイル名の情報やプロンプトなどの情報を受け取り、google.script.runでmain.gsのsummarizeVideo関数を呼び出しています。
また、結果はMarkdown形式で埋め込んでいるので、画面に表示するとともにコピーができると便利かと思ったのでコピー用のボタンを付けています。
function copyToClipboard() {
const copyButton = document.getElementById('copyButton');
navigator.clipboard.writeText(markdownText).then(() => {
showToast('クリップボードにコピーしました。', copyButton);
}).catch(err => {
showToast('クリップボードへのコピーに失敗しました: ' + err, copyButton);
});
}
style.html
ローディング画面などのCSSです。
<style>
.loading-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
z-index: 1000;
text-align: center;
padding-top: 200px;
}
.spinner-border {
width: 3rem;
height: 3rem;
}
#progressBar {
width: 50%;
margin: 0 auto;
}
.range-container {
position: relative;
}
.range-container input[type="range"] {
width: 100%;
}
.range-container .range-track {
position: absolute;
top: 50%;
left: 0;
height: 4px;
background-color: #0d6efd;
pointer-events: none;
transform: translateY(-50%);
}
</style>
main.gs
実際にGemini APIを呼び出している処理です。
summarizeVideo関数内のREGIONとPROJECT_IDはご自身のものに置き換えて下さい。
// JWTを生成するための関数
function getServiceAccountToken() {
const scriptProperties = PropertiesService.getScriptProperties();
const SERVICE_ACCOUNT_EMAIL = scriptProperties.getProperty('SERVICE_ACCOUNT_EMAIL');
const PRIVATE_KEY = scriptProperties.getProperty('PRIVATE_KEY').replace(/\\n/g, '\n');
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: SERVICE_ACCOUNT_EMAIL,
sub: SERVICE_ACCOUNT_EMAIL,
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
scope: 'https://www.googleapis.com/auth/cloud-platform'
};
const base64Header = Utilities.base64EncodeWebSafe(JSON.stringify({alg: 'RS256', typ: 'JWT'}));
const base64Payload = Utilities.base64EncodeWebSafe(JSON.stringify(payload));
const signatureInput = base64Header + '.' + base64Payload;
const signature = Utilities.computeRsaSha256Signature(signatureInput, PRIVATE_KEY);
const base64Signature = Utilities.base64EncodeWebSafe(signature);
const jwt = base64Header + '.' + base64Payload + '.' + base64Signature;
const response = UrlFetchApp.fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
contentType: 'application/x-www-form-urlencoded',
payload: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt
}
});
const responseData = JSON.parse(response.getContentText());
return responseData.access_token;
}
function summarizeVideo(fileName, prompt, startTime, endTime, temperature) {
const bucketName = PropertiesService.getScriptProperties().getProperty('BUCKET_NAME');
const videoUri = `gs://${bucketName}/${fileName}`;
const REGION = "<リージョン名>";
const PROJECT_ID = "<プロジェクトID>";
const MODEL = "gemini-1.5-flash";
const token = getServiceAccountToken();
const apiUrl = `https://${REGION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${REGION}/publishers/google/models/${MODEL}:generateContent`;
const parts = [
{
text: prompt,
},
{
fileData: {
mimeType: "video/mp4",
fileUri: videoUri
}
}
];
// videoMetadataの設定
if (startTime || endTime) {
const videoMetadata = {};
if (startTime) {
const [startMinutes, startSeconds] = startTime.split(':').map(Number);
videoMetadata.startOffset = {
seconds: startMinutes * 60 + startSeconds,
nanos: 0
};
}
if (endTime) {
const [endMinutes, endSeconds] = endTime.split(':').map(Number);
videoMetadata.endOffset = {
seconds: endMinutes * 60 + endSeconds,
nanos: 0
};
}
parts[1].videoMetadata = videoMetadata;
}
const requestBody = {
contents: [
{
parts: parts,
role: "user"
}
],
generationConfig : {
temperature: parseFloat(temperature)
}
};
console.log(requestBody);
try {
const response = UrlFetchApp.fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
payload: JSON.stringify(requestBody),
muteHttpExceptions: true,
escaping: false
});
const responseBody = response.getContentText();
const data = JSON.parse(responseBody);
const responseText = data.candidates[0].content.parts[0].text;
return { summary: responseText };
} catch (error) {
console.error('Error:', error);
throw new Error('Error summarizing video');
}
}
function savePromptTemplate(templateName, prompt) {
const sheet = SpreadsheetApp.getActive().getSheetByName('template');
const lastRow = sheet.getLastRow();
sheet.appendRow([templateName, prompt]);
return 'テンプレートが保存されました。';
}
メインの処理はsummarizeVideo関数で、これが議事録作成ボタンを押された時に呼ばれています。内容としては画面で設定した内容を元にAPIへリクエストしています。
リファレンスを見ていると開始時間や終了時間を秒単位で指定できるようだったので、画面側で指定した場合はパラメータとして含めるようにしてみました。
getServiceAccountToken関数は、サービスアカウントの情報からトークンを生成しています。savePromptTemplate関数については、プロンプトをテンプレートとして保存するための処理です。「テンプレートを保存」というボタンを付けているので、それを押すと実行されます。
使用方法
GASのファイル作成後はWebアプリとしてデプロイして試してみてください。
今回以下の記事を一部参考にしていますが、その中で使用されている総務省の動画を使って簡単に試してみました。(会議では無いので議事録ではないですが。。)
使用したデータは以下からダウンロードしたものです。
まとめ
GASとGemini 1.5 Flashを使用して、動画から議事録や要約を作るためのWebアプリを作成してみました。動画を直接理解してくれるのは、使用するユーザーにとっては楽で良いですね。個人的な感覚では精度はプロンプトによって変わるところも多いと思っているので、今回はプロンプトをユーザー側で編集出来たり、保存できたりという機能も付けてみることにしました。
参考にしていただけたら幸いです。ご覧いただきありがとうございました。