0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

グーグル検索 AIモードにカメラ機能を追加する

0
Posted at

はじめに

Google検索のAIモード、便利ですよね。
PCでの作業中に「手元にあるこの基板や書類をサッと撮影して質問したい!」と思うことがよくありますが、現在のデスクトップ版Google検索には「カメラを起動して直接アップロード」するボタンがありません。

「ないなら作ればいい」の精神で、UIを改造して撮影 → 即貼り付けを可能にするブックマークレットを開発したのでRPG風で解説していきます
非公式なのでバグやアップデートにて使えなくなる可能性はあります。


ミッション:カメラ機能を追加せよ

【第一の敵】 描画のたびに消滅するボタンが現れた

特定のクラス名を持つ親要素に新しいカメラボタンをつくる設計にした

  • 課題: 敵(Google検索のUI)は非常に動的です。攻撃するたびに(検索ワードを変えたりタブを切り替えたりする)たびにDOMが再描画され、注入したボタンが跡形もなく消えた。
  • 突破策:UIの再描画を常時監視。ターゲットが出現するたびに攻撃する(ボタンを再注入する)方式にした

【第二の敵】 複雑な鍵(デザイン)が現れた

ボタンを追加できても、偽物の鍵(自前のCSS)ではGoogleのUIの中で浮いてしまうという問題が発生した。

  • 課題: 複雑なボタンのスタイリングやホバー効果を自力で再現するのは、大変

  • 突破策: 既存の「画像をアップロード」ボタンを利用して公式のCSSクラスをそのまま継承することで、偽物の鍵(本物のデザイン)を実現

【第三の試練】 セキュルティ制限が現れた

ここで勇者一行(開発者)大ダメージ

  • 課題: JS側から アップロードするのは難しい

  • 突破策: 敵(Googleの入力欄)が「クリップボードからの画像貼り付け(Ctrl+V)」に標準対応している点に着目。撮影した画像を canvas に描き出し、navigator.clipboard.write を使ってユーザーのクリップボードへ直接バイナリを送り込む実装を採用

  • 結果: 敵をたおしてミッションをクリアしました(「撮影」→「Ctrl + V(貼り付け)」という、シンプルながらも確実かつ安全に動作するブックマークレットが完成)


完成したブックマークレットコード

javascript:(function(){const LABEL_TEXT="カメラで撮影";const TARGET_TEXT="画像をアップロード";const MODAL_ID='gemini-camera-official-ui';if(!document.getElementById(MODAL_ID)){const style=document.createElement('style');style.textContent=`@keyframes geminiModalIn {from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); }} @keyframes geminiFlash {0% { opacity: 0.7; } 100% { opacity: 0; }} .g-cam-btn { transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); cursor: pointer; border: none; } .g-cam-btn:hover { background-color: rgba(68, 71, 70, 0.12) !important; } .g-cam-btn:active { transform: scale(0.92); } #cam-shutter:active .shutter-inner { transform: scale(0.85); }`;document.head.appendChild(style);const wrapper=document.createElement('div');wrapper.id=MODAL_ID;wrapper.style=`position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.2); backdrop-filter: blur(8px); z-index: 10001; display: none; align-items: center; justify-content: center; transition: opacity 0.3s ease; opacity: 0;`;wrapper.innerHTML=`<div id="cam-card" style="position: relative; width: 92%; max-width: 900px; height: 85vh; background: #ffffff; border-radius: 28px; overflow: hidden; box-shadow: 0 24px 80px rgba(0,0,0,0.15); display: flex; flex-direction: column;"><div style="padding: 16px 24px; display: flex; justify-content: space-between; align-items: center;"><span style="font-family: 'Google Sans', sans-serif; font-size: 16px; color: #444746; font-weight: 500;">カメラ</span><button id="cam-close" class="g-cam-btn" style="background: transparent; color: #444746; padding: 8px; border-radius: 50%; font-size: 20px;">✕</button></div><div style="flex: 1; position: relative; margin: 0 12px 12px; background: #000; border-radius: 20px; overflow: hidden;"><video id="cam-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover;"></video><div id="cam-flash" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: white; opacity: 0; pointer-events: none; z-index: 10;"></div><div style="position: absolute; bottom: 24px; right: 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; z-index: 20;"><button id="cam-switch" class="g-cam-btn" style="width: 48px; height: 48px; background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); border-radius: 50%; color: white; display: flex; align-items: center; justify-content: center;"><svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M19 8l-4 4h3c0 3.31-2.69 6-6 6-1.01 0-1.97-.25-2.8-.7l-1.46 1.46C8.97 19.54 10.41 20 12 20c4.42 0 8-3.58 8-8h3l-4-4zM6 12c0-3.31 2.69-6 6-6 1.01 0 1.97.25 2.8.7l1.46-1.46C15.03 4.46 13.59 4 12 4c-4.42 0-8 3.58-8 8H1l4 4 4-4H6z"/></svg></button><button id="cam-shutter" style="width: 80px; height: 80px; background: white; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 20px rgba(0,0,0,0.3); transition: transform 0.1s ease;"><div class="shutter-inner" style="width: 64px; height: 64px; border: 2px solid #000; border-radius: 50%; transition: transform 0.1s ease;"></div></button></div></div></div><canvas id="cam-canvas" style="display: none;"></canvas>`;document.body.appendChild(wrapper);}let stream=null,mode="environment";const modal=document.getElementById(MODAL_ID);const card=document.getElementById('cam-card');const video=document.getElementById('cam-video');const flash=document.getElementById('cam-flash');const stop=()=>{if(stream)stream.getTracks().forEach(t=>t.stop());modal.style.opacity='0';setTimeout(()=>{modal.style.display='none'},300)};const start=async()=>{try{if(stream)stream.getTracks().forEach(t=>t.stop());stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:mode,width:{ideal:1920},height:{ideal:1080}}});video.srcObject=stream;video.style.transform=mode==="user"?"scaleX(-1)":"scaleX(1)";modal.style.display='flex';setTimeout(()=>{modal.style.opacity='1';card.style.animation='geminiModalIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards'},10)}catch(e){alert("カメラの起動に失敗しました。")}};const takePhoto=()=>{flash.style.animation='geminiFlash 0.4s ease-out';const c=document.getElementById('cam-canvas');c.width=video.videoWidth;c.height=video.videoHeight;const ctx=c.getContext('2d');if(mode==="user"){ctx.translate(c.width,0);ctx.scale(-1,1)}ctx.drawImage(video,0,0);c.toBlob(async b=>{const item=new ClipboardItem({"image/png":b});await navigator.clipboard.write([item]);setTimeout(()=>{flash.style.animation='';stop();const ed=document.querySelector('[contenteditable="true"]')||document.querySelector('textarea');if(ed)ed.focus()},300)},'image/png')};document.getElementById('cam-close').onclick=stop;document.getElementById('cam-shutter').onclick=takePhoto;document.getElementById('cam-switch').onclick=()=>{mode=(mode==="user"?"environment":"user");start()};function injectUI(){if(document.querySelector('[data-camera-injected]'))return;const target=Array.from(document.querySelectorAll('.TgKHtb')).find(el=>el.textContent.trim()===TARGET_TEXT);if(!target)return;const row=target.closest('.q3INob');if(!row)return;const newRow=row.cloneNode(true);newRow.setAttribute('data-camera-injected','true');newRow.querySelector('.TgKHtb').textContent=LABEL_TEXT;const svg=newRow.querySelector('.zDosvf svg');if(svg){svg.setAttribute('viewBox','0 -960 960 960');svg.innerHTML='<path fill="currentColor" d="M480-160q-133 0-226.5-93.5T160-480q0-133 93.5-226.5T480-800q133 0 226.5 93.5T800-480q0 133-93.5 226.5T480-160Zm0-80q100 0 170-70t70-170q0-100-70-170t-170-70q-100 0-170 70t-70 170q0 100 70 170t170 70Z"/>'}if(newRow.querySelector('input'))newRow.querySelector('input').remove();newRow.onclick=(e)=>{e.preventDefault();e.stopPropagation();start()};row.parentNode.insertBefore(newRow,row)}const observer=new MutationObserver(injectUI);observer.observe(document.body,{childList:true,subtree:true});injectUI()})();

まとめ:ミッション報酬(得られた知見)

  • よく動く敵(動的サイト)の攻略: 文字列などを基準として場所を決める
  • 擬態の仕方: 既存要素をクローンすることで、違和感がなくなる
  • 最強の敵(制約)の回避: 柔軟な設計の重要性(クリップボードをつかう)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?