Google Apps Script のウェブアプリから、常に最前面に表示される小窓を開き、ボタンで操作できるようにしました。
使ったのは、Picture-in-Picture(PiP)です。
PiP は、動画を見ながら別の作業をするときによく使われる機能ですが、ウェブアプリの操作パネルにも利用できます。
PiP には、大きく分けて2種類あります。
| 種類 | 表示できるもの | 操作 |
|---|---|---|
| Document PiP | 任意の HTML | 自由にボタンなどを配置できる |
| 動画 PiP | 動画 | 再生・一時停止など、決められたボタンを使う |
Document PiP の方が自由度は高いのですが、Apps Script の HTML サービスから直接開くことはできません。また、前回の記事で使った about:blank の別ウィンドウから開こうとしても、直後に閉じてしまいました。
一方、動画 PiP は Apps Script でも利用できました。 しかも、canvas と Media Session API を組み合わせれば、小窓に表示する内容を変えたり、ボタンを押して処理を実行したりできます。
例えば、次のようなコードです。
// canvas に描いた内容を動画にする
pipVideo.srcObject = canvas.captureStream(5);
// 動画の「次へ」ボタンに処理を割り当てる
navigator.mediaSession.setActionHandler(
'nexttrack',
() => captureFrame('nexttrack')
);
// クリックイベントの中で動画 PiP を開く
await pipVideo.requestPictureInPicture();
前回は、Apps Script のウェブアプリから画面キャプチャを行う方法を紹介しました。
今回はそのアプリに、作業中の画面の上に置いておける操作パネルを追加します。小窓から画面キャプチャを指示し、処理の状態や取得画像の縮小表示もできるようにしました。動画操作のためのボタンで画面をキャプチャーするのは気持ち悪いのですが、ちゃんと動きます。
まず動作するコードを紹介し、その後に仕組みと、Document PiP が使えなかった理由を説明します。
試してみる
Google Apps Script で新しいプロジェクトを作成します。
必要なファイルは3つです。
-
Code.gs:ウェブアプリの HTML を返す -
Index.html:別ウィンドウの起動、画像表示 -
Popup.html:画面共有、動画 PiP、ボタン操作
1. Code.gs
サーバー側のコードです。
Popup.html を Base64 でエンコードし、Index.html のテンプレートに渡しています。これにより、別のウェブサーバーを用意せず、Apps Script の HTML ファイルを別ウィンドウで実行できます。
function doGet() {
const template = HtmlService.createTemplateFromFile('Index');
template.popupHtmlBase64 = Utilities.base64Encode(
HtmlService.createHtmlOutputFromFile('Popup').getContent(),
Utilities.Charset.UTF_8
);
return template.evaluate().setTitle('Apps Script PiP Test');
}
2. Index.html
元のウェブアプリの画面です。
「操作ウィンドウを開く」ボタンで別ウィンドウを作り、Popup.html の内容を書き込みます。
別ウィンドウから送信された画像は、postMessage() で受け取って表示します。
Index.html のコードを表示
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: system-ui, sans-serif; padding: 20px; max-width: 720px; margin: auto; }
button { padding: 10px 16px; cursor: pointer; }
img { display: block; max-width: 100%; margin-top: 16px; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<h2>Apps Script PiP Test</h2>
<button id="open">操作ウィンドウを開く</button>
<p id="status">待機中</p>
<img id="shot" alt="キャプチャ画像">
<pre id="log"></pre>
<script>
const $ = id => document.getElementById(id);
const ORIGIN = location.origin;
const POPUP_HTML_BASE64 = '<?= popupHtmlBase64 ?>';
let popup = null;
let token = null;
function log(message) {
$('log').textContent += new Date().toLocaleTimeString() + ' ' + message + '\n';
}
$('open').addEventListener('click', () => {
if (popup && !popup.closed) {
popup.focus();
return;
}
// 前回の記事と同様、ポップアップは about:blank から生成する。
popup = window.open('', '_blank', 'width=540,height=650');
if (!popup) {
log('ポップアップがブロックされました');
return;
}
token = crypto.randomUUID();
popup.name = token;
// Apps Script のファイル Popup.html をポップアップに書き込む。
const bytes = Uint8Array.from(atob(POPUP_HTML_BASE64), c => c.charCodeAt(0));
const html = new TextDecoder().decode(bytes);
popup.document.open();
popup.document.write(html);
popup.document.close();
$('status').textContent = '操作ウィンドウを開きました';
});
window.addEventListener('message', event => {
if (event.origin !== ORIGIN || event.source !== popup) return;
const data = event.data;
if (!data || data.token !== token) return;
if (data.type === 'image') {
$('shot').src = data.image;
$('status').textContent = data.count + ' 回キャプチャしました';
log('静止画を受信: ' + data.width + '×' + data.height);
} else if (data.type === 'status') {
$('status').textContent = data.message;
log(data.message);
}
});
</script>
</body>
</html>
3. Popup.html
別ウィンドウで動くアプリです。
画面共有を開始し、canvas の映像を動画 PiP に表示します。PiP の操作ボタンが押されたら、共有中の画面から静止画を取得して、元のウェブアプリへ送信します。
Popup.html のコードを表示
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PiP Controller</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 10px 14px; margin: 4px; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
#screen { display: block; width: 100%; margin-top: 12px; }
#pipVideo { position: fixed; width: 2px; height: 2px; opacity: 0; pointer-events: none; }
</style>
</head>
<body>
<h3>画面共有と PiP</h3>
<button id="share">画面共有を開始</button>
<button id="pip" disabled>小窓(PiP)を開く</button>
<button id="stop" disabled>画面共有を停止</button>
<p id="status">準備中</p>
<video id="screen" autoplay muted playsinline></video>
<video id="pipVideo" autoplay muted playsinline></video>
<script>
const $ = id => document.getElementById(id);
const TOKEN = window.name;
const ORIGIN = location.origin;
const screen = $('screen');
const pipVideo = $('pipVideo');
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 360;
const ctx = canvas.getContext('2d');
const snapshot = document.createElement('canvas');
const snapshotCtx = snapshot.getContext('2d');
let shareStream = null;
let count = 0;
let status = 'PiP のボタンを押してください';
let lastAction = '';
function report(type, extra = {}) {
if (!window.opener) return;
window.opener.postMessage({ type, token: TOKEN, ...extra }, ORIGIN);
}
function updateStatus(text) {
status = text;
$('status').textContent = text;
draw();
report('status', { message: text });
}
function draw() {
ctx.fillStyle = '#207773';
ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = '#fff';
ctx.font = 'bold 28px sans-serif';
ctx.fillText('PiP 操作パネル', 24, 46);
ctx.font = '21px sans-serif';
ctx.fillText('⏯ / 次へ で静止画を取得', 24, 91);
ctx.font = '24px sans-serif';
ctx.fillText('操作回数: ' + count, 24, 146);
ctx.font = '18px sans-serif';
ctx.fillText('操作: ' + (lastAction || 'なし'), 24, 186);
ctx.fillText(new Date().toLocaleTimeString(), 24, 224);
ctx.font = '17px sans-serif';
ctx.fillText(shareStream ? '画面共有中' : '画面共有前', 24, 265);
if (snapshot.width && snapshot.height) {
ctx.drawImage(snapshot, 400, 110, 212, 145);
}
}
function captureFrame(action) {
count++;
lastAction = action;
if (!shareStream || screen.readyState < 2) {
updateStatus('ボタンは反応しました(画面共有は未開始)');
return;
}
try {
const scale = Math.min(1, 1280 / screen.videoWidth);
snapshot.width = Math.round(screen.videoWidth * scale);
snapshot.height = Math.round(screen.videoHeight * scale);
snapshotCtx.drawImage(screen, 0, 0, snapshot.width, snapshot.height);
report('image', {
image: snapshot.toDataURL('image/jpeg', 0.75),
count,
width: snapshot.width,
height: snapshot.height
});
updateStatus('静止画を取得しました (' + action + ')');
} catch (e) {
updateStatus(e.name + ': ' + e.message);
}
}
// 動画の操作ボタンを静止画取得のトリガーにする。
for (const action of ['play', 'pause', 'nexttrack']) {
navigator.mediaSession.setActionHandler(action, () => captureFrame(action));
}
draw();
pipVideo.srcObject = canvas.captureStream(5);
setInterval(draw, 500); // 時刻や状態の表示を更新する
pipVideo.addEventListener('loadeddata', () => {
$('pip').disabled = false;
updateStatus('PiP を開けます');
});
pipVideo.play().catch(e => updateStatus('動画の開始に失敗: ' + e.message));
$('pip').addEventListener('click', async () => {
// play() の await をここに挟まず、ユーザー操作から直接要求する。
try {
await pipVideo.requestPictureInPicture();
updateStatus('PiP を開きました');
} catch (e) {
updateStatus('PiP 失敗: ' + e.name + ' / ' + e.message);
}
});
$('share').addEventListener('click', async () => {
try {
shareStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
screen.srcObject = shareStream;
await screen.play();
$('share').disabled = true;
$('stop').disabled = false;
shareStream.getVideoTracks()[0].addEventListener('ended', stopShare);
updateStatus('画面共有中(PiP のボタンで取得できます)');
} catch (e) {
updateStatus('画面共有失敗: ' + e.name + ' / ' + e.message);
}
});
function stopShare() {
if (shareStream) {
shareStream.getTracks().forEach(track => track.stop());
shareStream = null;
}
screen.srcObject = null;
$('share').disabled = false;
$('stop').disabled = true;
updateStatus('画面共有を停止しました');
}
$('stop').addEventListener('click', stopShare);
window.addEventListener('pagehide', () => {
if (shareStream) shareStream.getTracks().forEach(track => track.stop());
});
</script>
</body>
</html>
4. 動作確認
Apps Script の「デプロイ」→「新しいデプロイ」から、ウェブアプリとして公開します。
Chrome でウェブアプリを開き、次の順番で操作してください。
- 「操作ウィンドウを開く」を押す
- 開いた別ウィンドウで「画面共有を開始」を押す
- 共有するウィンドウを選択する
- 「小窓(PiP)を開く」を押す
- PiP の「次へ」または「再生/一時停止」を押す
PiP は作業中のウィンドウより前面に表示されます。
ボタンを押すと、画面共有しているウィンドウの静止画が取得され、元の Apps Script の画面に表示されました。
PiP 自体にも、操作回数、操作名、時刻、画面共有の状態、直近のキャプチャ画像を表示できます。
今回は、Apps Script だけで動作しました。外部のウェブサーバーやブラウザー拡張機能は使っていません。
実装のポイント
1. canvas の内容を動画として表示する
動画 PiP は、本来 <video> 要素の映像を表示するための機能です。任意の HTML を直接配置することはできません。
そこで、canvas に描いた内容を動画に変換します。
pipVideo.srcObject = canvas.captureStream(5);
canvas.captureStream(5) は、canvas の描画内容を MediaStream として取得します。引数の 5 は最大フレームレートです。
今回のコードでは、500 ms ごとに canvas を描き直しています。
setInterval(draw, 500);
これにより、小窓の表示を更新できます。
例えば、今回表示しているのは以下の情報です。
- 操作回数
- 最後に押されたボタン
- 現在時刻
- 画面共有の状態
- 直近のキャプチャ画像
最後に取得した画像も、drawImage() で canvas に描いています。
動画 PiP ですが、実際にはウェブアプリの状態表示として使っているわけです。
2. Media Session で操作ボタンを受け取る
もう1つのポイントが Media Session API です。
Media Session API は、動画や音楽の再生操作をブラウザーや OS から受け取るための API です。
Chrome の動画 PiP でも利用でき、play、pause、nexttrack などの操作に処理を割り当てられます。
for (const action of ['play', 'pause', 'nexttrack']) {
navigator.mediaSession.setActionHandler(
action,
() => captureFrame(action)
);
}
今回のコードでは、どの操作を受け取っても captureFrame() を呼び出します。
pause のハンドラーを設定すると、今回の Chrome では通常の一時停止処理の代わりに指定した処理が実行され、動画も停止しませんでした。
そのため、再生・一時停止ボタンを画面キャプチャ用のボタンとして利用できます。
ボタンのアイコンは変えられませんが、canvas の表示に「⏯ / 次へ で静止画を取得」と書いて補っています。
3. 画面キャプチャの指示を小窓から出す
画面共有は、別ウィンドウで getDisplayMedia() を呼び出して開始します。
shareStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false
});
一度画面共有を開始すれば、共有中の映像から静止画を取得できます。
snapshotCtx.drawImage(
screen,
0,
0,
snapshot.width,
snapshot.height
);
PiP のボタンを押すと、共有中の映像を canvas に描き、JPEG に変換します。
snapshot.toDataURL('image/jpeg', 0.75);
その画像を postMessage() で元の Apps Script の画面へ送っています。
このように、PiP 側は操作のきっかけを作り、実際の画面キャプチャは別ウィンドウで実行する構成にしています。
4. PiP はクリック操作から直接開く
PiP を開くにはユーザー操作が必要です。
今回のコードでは、PiP 用の動画をあらかじめ再生しておき、ボタンが押されたら requestPictureInPicture() を呼び出しています。
$('pip').addEventListener('click', async () => {
await pipVideo.requestPictureInPicture();
});
クリックイベントの中で await video.play() を挟むと、ユーザー操作に紐づく一時的な権限が失われる可能性があります。
そのため、動画の再生と PiP の起動を分けました。
なぜ Document Picture-in-Picture を使わなかったのか
ここからは、Document PiP を試したときの話です。
Document Picture-in-Picture は、任意の HTML を表示できる PiP です。
通常のボタンや文字入力も配置できるので、今回のような操作パネルには、むしろこちらが適しています。
const pip = await documentPictureInPicture.requestWindow({
width: 320,
height: 200
});
pip.document.body.append(button);
しかし、前回の記事で作った Apps Script の別ウィンドウから実行すると、小窓がすぐに閉じてしまいました。
requestWindow() は成功するのに閉じる
requestWindow() はウィンドウを返します。Promise が reject されるわけではないので、catch ではエラーを検知できません。
返ってきたウィンドウの状態を記録すると、次のようになっていました。
+15 ms 開いた: 開いている, 0×0, 表示状態 visible
+17 ms 閉じられた (pagehide)
小窓は大きさ 0×0 で作られ、わずか2 ms 後に閉じられていました。
こちらのコードで閉じているわけではありません。pagehide を監視して初めて分かりました。
原因を切り分ける
Apps Script を使わない最小のページを手元で配信し、ページの開き方を変えて試しました。
| 小窓を開いたページ | 起動元 (opener) | 結果 | |
|---|---|---|---|
| A | 手元で配信した http のページ |
なし | 開いた |
| B |
window.open('') + document.write()(about:blank) |
あり | 直後に閉じた |
| C |
window.open(blobUrl)(blob:) |
あり | 直後に閉じた |
| D | window.open(blobUrl, '_blank', 'noopener') |
なし | 直後に閉じた |
| E | C を開いたあと window.opener = null
|
なし | 直後に閉じた |
| F |
window.open('/f.html')(手元で配信した http のページ) |
あり | 開いた |
A と F では小窓が開き、B〜E では直後に閉じました。
ウィンドウの種類(ポップアップかタブか)や、起動元とのつながりを変えても結果は変わりませんでした。
今回の検証では、ページの URL によって結果が分かれました。
Chromium のソースコードにも制限があった
調べてみると、Chromium の picture_in_picture_window_manager.cc に、Document PiP を許可する URL の判定がありました。
Chromium: picture_in_picture_window_manager.cc
IsSupportedForDocumentPictureInPicture() という関数で、HTTPS、localhost、file: などの URL を許可しています。
一方、about:blank と blob: は許可対象に含まれていません。
コードのコメントには、タイトルバーに適切に表示できない URL を許可すると、ユーザーに誤解を与える可能性があるため、と説明されています。
Document PiP はタイトルバーに元のサイトを表示するので、そのための制限のようです。
なお、HTTP のページなら何でも許可されるわけではありません。今回の手元での検証結果を、一般の HTTP サイトにそのまま当てはめることはできません。
Apps Script だけで完結させるのは難しい
Document PiP の requestWindow() は、トップレベルのウィンドウから呼び出す必要があります。iframe 内から直接呼び出すことはできません。
Apps Script の HTML サービスは iframe 内で実行されます。
そこで、前回の記事では about:blank の別ウィンドウを開いて iframe の制限を回避しました。しかし、その別ウィンドウは Document PiP の URL 制限に引っかかります。
通常の HTTPS ページを別途用意すれば、Document PiP を利用する方法も考えられます。
ただし、外部に HTML を配置する必要があり、Apps Script だけでは完結しなくなります。
そこで今回は、Apps Script だけで動作する動画 PiP を採用しました。
検証結果
Windows のデスクトップ版 Chrome 157 で検証した結果です。
| 項目 | 結果 |
|---|---|
| Apps Script から別ウィンドウを開く | 成功 |
| 別ウィンドウから画面共有を開始する | 成功 |
| 動画 PiP を開く | 成功 |
| PiP を作業中のアプリより前面に表示する | 成功 |
| 「次へ」「再生/一時停止」で静止画を取得する | 成功 |
| 取得画像を元のウェブアプリに表示する | 成功 |
| PiP に操作回数、時刻、状態、縮小画像を表示する | 成功 |
制限事項と注意点
自由なボタンは配置できない
動画 PiP に表示できる操作ボタンは、ブラウザーが用意したものに限られます。
任意の HTML ボタンを追加したり、文字入力欄を設けたりすることはできません。
また、ボタンの種類や表示方法もブラウザーの実装に依存します。
ただし、canvas に描く内容は自由に変えられます。今回のように状態や操作方法を表示する用途なら、十分実用的です。
画面共有にはユーザーの許可が必要
PiP のボタンを押しただけで、画面共有の許可を自動的に得られるわけではありません。
最初に getDisplayMedia() を呼び出し、ユーザーが共有対象を選択する必要があります。
今回のコードは、一度許可された共有映像から静止画を取得しています。PiP のボタンを押すたびに共有許可を要求する仕組みではありません。
画面全体を共有すると小窓も写る
PiP も画面の一部なので、画面全体を共有すると、キャプチャ画像に小窓が写り込むことがあります。
画面を読み取る用途では、対象のウィンドウだけを共有した方が扱いやすくなります。
ブラウザーの実装に依存する
Document PiP の URL 制限は Chromium のソースコードでも確認できました。
一方、動画 PiP に表示されるボタンや、Media Session の操作を受け取ったときの挙動は、Chrome の実装に依存します。
将来のバージョンや他のブラウザーで、同じように動作するとは限りません。
画面の操作履歴までは取得できない
今回できるのは、共有中の画面から静止画を取得することです。
画面上のマウスクリックやキーボード操作を、OS 全体から直接取得できるわけではありません。
操作内容を分析したい場合は、取得した画像を比較するなど、別の処理が必要です。
また、この検証コードは取得画像をブラウザー内の元の画面へ返すだけで、Gemini などの外部 API には送信していません。
まとめ
Apps Script のウェブアプリに、Picture-in-Picture を使った操作パネルを追加できました。
最初は自由に HTML を配置できる Document PiP を試しましたが、前回の画面キャプチャで使った about:blank の別ウィンドウでは、開いた直後に閉じられてしまいました。
Chromium のソースコードを確認すると、Document PiP には URL の制限があり、about:blank や blob: は許可されていませんでした。
そこで、動画用の Picture-in-Picture を利用しました。
canvas の内容を動画として表示し、Media Session API で動画の操作ボタンを受け取る。
これにより、Apps Script だけで、表示内容を更新できる最前面の操作パネルを作れました。
今回は画面キャプチャの指示に使いましたが、処理の開始や停止、状態確認など、ほかのウェブアプリにも応用できそうです。
次は、この小窓から指示して Gemini に画面を読み取らせた結果を紹介したいと思います。
Gemini Live に画面を見せる方法と、画像の読み取りが得意なモデルで書き写させる方法では、精度に大きな差が出ました。
参考資料
- Google Apps Scriptのウェブアプリで画面キャプチャする方法 - iframeの制限を別ウィンドウで回避
- Picture-in-Picture for any Element, not just <video> (Chrome for Developers)
- DocumentPictureInPicture: requestWindow() (MDN)
- Chromium: picture_in_picture_window_manager.cc
- Watch video using Picture-in-Picture (Chrome for Developers)
- HTML Service: Restrictions (Google for Developers)
- Picture-in-Picture API (MDN)
- Media Session API (MDN)
- HTMLCanvasElement: captureStream() (MDN)
- Window: postMessage() (MDN)

