用 Gemini Canvas 搭配 GAS 自製了一個 Chromebook 拍貼機:
1.可更換邊框
2.拍照後將檔案上傳到雲端硬
3.可用 手機掃描 QR Code 下載檔案
4.在鏡頭前比 🖐 可啟動倒數自拍模式

🎯測試用 Chroembook 拍貼機:https://gemini.google.com/share/89b870dc96e3
🎯測試用拍貼機上傳資料夾(拍照測試後記得刪除):
一、建立一個 Google 試算表 將工作表名稱命名為「設定」
- 資料夾 ID 為 拍照後想上傳的資料夾
- 相片邊框網址為 設計好的邊框上傳到公開的 Google 相簿

// 🌟 1. 請填入你剛剛建立的 Google 試算表 ID 🌟
// 例如: const SPREADSHEET_ID = '1BxiMVs0XRX5nZYz140...';
const SPREADSHEET_ID = 'Google 試算表 ID';
// 讀取試算表設定的共用函數
function getConfig() {
const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName('設定');
if (!sheet) throw new Error("找不到名為『設定』的工作表,請確認試算表下方的工作表名稱。");
const data = sheet.getDataRange().getValues();
const config = {
folderId: '',
title: '互動拍貼機',
frames: []
};
// 從第二列開始讀取 (略過第一列的標題:A1=名稱, B1=設定值)
for (let i = 1; i < data.length; i++) {
const key = data[i][0].toString().trim();
const value = data[i][1].toString().trim();
if (key === '資料夾ID') config.folderId = value;
if (key === '標題') config.title = value;
if (key === '相片邊框' && value !== '') config.frames.push(value);
}
// 給一個預設相框避免試算表沒填資料時網頁出錯
if (config.frames.length === 0) {
config.frames.push('https://wsrv.nl/?url=csps.chc.edu.tw/laravel-filemanager/photos/3/BG.png');
}
return config;
}
// 處理 GET 請求 (前端一開始開啟網頁時,會發送 GET 來要設定檔)
function doGet(e) {
try {
// 當網址帶有 ?action=getConfig 時,回傳設定資料
if (e && e.parameter && e.parameter.action === 'getConfig') {
const config = getConfig();
// 為了安全考量,我們不把 folderId (資料夾ID) 傳給前端
// 前端只需要知道「標題」跟「相片邊框網址」就可以運作了
const frontendConfig = {
title: config.title,
frames: config.frames
};
return ContentService.createTextOutput(JSON.stringify(frontendConfig))
.setMimeType(ContentService.MimeType.JSON);
}
// 如果單純用瀏覽器開啟網址,顯示運作正常的提示文字
return ContentService.createTextOutput("拍貼機後端伺服器運作正常!請從前端網頁發送相片 (POST) 或附加 ?action=getConfig 取得設定。");
} catch (error) {
// 發生錯誤時回傳錯誤訊息給前端
return ContentService.createTextOutput(JSON.stringify({ error: error.toString() }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// 處理 POST 請求 (前端拍完照後,傳送影像資料來這裡上傳)
function doPost(e) {
try {
const data = JSON.parse(e.postData.contents);
const base64Data = data.image.split(',')[1];
// 將 Base64 轉換回圖片檔案
const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), 'image/jpeg', data.filename);
// 【動態取得資料夾 ID】: 每次拍照都去試算表查最新的資料夾 ID
const config = getConfig();
if (!config.folderId) throw new Error("尚未在試算表設定『資料夾ID』");
const folder = DriveApp.getFolderById(config.folderId);
// 1. 建立檔案
const file = folder.createFile(blob);
// 2. 設定權限 (加上 try-catch,避免學校/公司教育帳號阻擋分享導致報錯)
try {
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
} catch (shareError) {
// 如果被網域安全性阻擋,忽略此錯誤,讓程式繼續往下走以回傳成功網址
}
// 3. 取得直接下載網址 (前端產生 QR Code 用)
const downloadUrl = "https://drive.google.com/uc?id=" + file.getId() + "&export=download";
return ContentService.createTextOutput(JSON.stringify({
success: true,
url: downloadUrl
})).setMimeType(ContentService.MimeType.JSON);
} catch (error) {
return ContentService.createTextOutput(JSON.stringify({
success: false,
error: error.toString()
})).setMimeType(ContentService.MimeType.JSON);
}
}





https://script.google.com/macros/s/AKfycbxjpqAu69aYgzrgRgbPSL1PE_..................../exec
二、將下列程式複製貼到 Gemini Canvas
非常重要,找到 /* ─── 常數 ─── */ const SCRIPT_URL = "貼上GAS 網頁佈署後的網址/exec";
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>互動拍貼機</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
<style>
body { font-family: 'PingFang TC', 'Microsoft JhengHei', sans-serif; }
.glass-panel {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
}
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.bg-checkerboard {
background-image:
linear-gradient(45deg, #e5e7eb 25%, transparent 25%),
linear-gradient(-45deg, #e5e7eb 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e5e7eb 75%),
linear-gradient(-45deg, transparent 75%, #e5e7eb 75%);
background-size: 10px 10px;
background-position: 0 0, 0 5px, 5px -5px, -5px 0px;
background-color: #f9fafb;
}
/* ── Video mirror fix ── */
#videoWrapper {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
}
#videoElement {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
-webkit-transform: scaleX(-1);
transform: scaleX(-1);
will-change: transform;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
/* ════════════════════════════════════════════
RESPONSIVE LAYOUT SYSTEM
════════════════════════════════════════════
Portrait mobile → column layout (camera top, controls bottom)
Landscape / ≥md → row layout (camera left, controls right)
════════════════════════════════════════════ */
#appContainer {
display: flex;
flex-direction: column; /* portrait default */
width: 98vw;
height: 96dvh; /* dvh = dynamic viewport height (handles mobile chrome bar) */
max-width: 1920px;
}
/* Camera: fills width in portrait, fills height in landscape */
#cameraContainer {
position: relative;
width: 100%;
flex: 0 0 62dvh; /* portrait: ~62% of screen height */
overflow: hidden;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
/* Control panel: short strip at bottom in portrait */
#controlPanel {
width: 100%;
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
padding: 10px 12px;
gap: 8px;
background: rgba(255,255,255,0.85);
}
/* ── Landscape mobile (e.g. phone rotated) ── */
@media (orientation: landscape) and (max-width: 1023px) {
#appContainer { flex-direction: row; }
#cameraContainer {
flex: 1 1 0;
height: 100%;
width: auto;
}
#controlPanel {
width: 200px;
flex: 0 0 200px;
height: 100%;
justify-content: center;
padding: 10px 10px;
gap: 8px;
}
/* Shrink QR code in landscape mobile */
#qrcode { width: 110px !important; height: 110px !important; }
#qrcode img, #qrcode canvas { width: 110px !important; height: 110px !important; }
.qr-wrapper { padding: 6px !important; }
#appTitle { font-size: 0.95rem !important; margin-bottom: 0 !important; }
#statusText { font-size: 0.65rem !important; }
}
/* ── Tablet & Desktop: proper sidebar ── */
@media (min-width: 768px) and (orientation: landscape),
(min-width: 1024px) {
#appContainer { flex-direction: row; }
#cameraContainer {
flex: 1 1 0;
height: 100%;
width: auto;
}
#controlPanel {
width: 220px;
flex: 0 0 220px;
height: 100%;
justify-content: center;
padding: 20px 16px;
gap: 14px;
}
}
@media (min-width: 1280px) {
#controlPanel { width: 280px; flex-basis: 280px; }
}
/* ── Compact layout helpers for portrait mobile ── */
@media (orientation: portrait) and (max-width: 767px) {
#appTitle { font-size: 1rem !important; margin-bottom: 0 !important; }
#statusText { font-size: 0.7rem !important; }
#captureBtn { padding: 10px !important; font-size: 0.9rem !important; }
.qr-wrapper { padding: 6px !important; }
#qrcode { width: 120px !important; height: 120px !important; }
#qrcode img, #qrcode canvas { width: 120px !important; height: 120px !important; }
#frameSelectionArea { margin: 0 !important; }
/* Make result controls horizontal in portrait to save vertical space */
#resultControls { gap: 6px !important; }
.btn-row { flex-direction: row !important; gap: 6px !important; margin-top: 0 !important; }
}
/* ── Orientation-change smooth transition ── */
#appContainer, #cameraContainer, #controlPanel {
transition: width 0.3s ease, height 0.3s ease, flex-basis 0.3s ease;
}
</style>
</head>
<body class="bg-gray-100 h-screen w-screen overflow-hidden flex items-center justify-center bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 relative">
<div id="appContainer" class="glass-panel rounded-3xl shadow-2xl overflow-hidden relative z-10">
<!-- 相機區 -->
<div id="cameraContainer">
<div id="videoWrapper">
<video id="videoElement"
playsinline
webkit-playsinline
muted>
</video>
</div>
<img id="frameImage"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
crossorigin="anonymous"
onerror="this.src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='"
class="absolute inset-0 w-full h-full object-cover pointer-events-none z-10 transition-opacity duration-300" />
<div id="gestureHint" class="absolute top-3 left-3 bg-black/50 text-white px-3 py-1.5 rounded-full text-xs flex items-center gap-1.5 z-20 backdrop-blur-sm border border-white/20 shadow-lg">
<span class="text-base">🖐</span>
<span class="font-bold tracking-wider">比出手掌自動拍照</span>
</div>
<!-- 倒數計時 -->
<div id="countdownOverlay" class="absolute inset-0 bg-black/40 hidden flex-col items-center justify-center text-white z-40 backdrop-blur-sm transition-all duration-300">
<span id="countText" class="text-[10rem] font-bold drop-shadow-[0_0_20px_rgba(255,255,255,0.8)] animate-pulse">3</span>
</div>
<img id="photoPreview" class="absolute inset-0 w-full h-full object-cover hidden z-20" alt="拍攝結果" />
</div>
<!-- 控制面板 -->
<div id="controlPanel">
<div class="text-center w-full flex-shrink-0">
<h1 id="appTitle" class="text-xl font-bold text-gray-800 mb-1">互動攝影站</h1>
<p id="statusText" class="text-xs text-gray-500">點擊下方按鈕拍照!</p>
</div>
<!-- 相框選擇 -->
<div id="frameSelectionArea" class="w-full flex flex-col items-center gap-1 hidden flex-shrink-0">
<p class="text-xs font-bold text-gray-500 tracking-wider">選擇相框款式</p>
<div class="relative w-full flex items-center justify-center">
<button id="scrollLeftBtn" class="absolute left-0 z-20 p-1 bg-white/90 hover:bg-white rounded-full shadow-md text-gray-600 hover:text-indigo-600 transition-all focus:outline-none -ml-1 hidden" style="top:50%;transform:translateY(calc(-50% - 4px))">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</button>
<div id="frameSelector" class="flex gap-3 overflow-x-auto w-full pb-2 pt-1 px-7 snap-x no-scrollbar scroll-smooth items-center"></div>
<button id="scrollRightBtn" class="absolute right-0 z-20 p-1 bg-white/90 hover:bg-white rounded-full shadow-md text-gray-600 hover:text-indigo-600 transition-all focus:outline-none -mr-1 hidden" style="top:50%;transform:translateY(calc(-50% - 4px))">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- 拍照按鈕 -->
<div id="captureControls" class="w-full flex flex-col gap-2 hidden flex-shrink-0">
<button id="captureBtn" class="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold text-base shadow-lg shadow-indigo-200 transition-all flex items-center justify-center gap-2">
<i data-lucide="camera"></i> 拍下精彩瞬間
</button>
</div>
<!-- 結果控制 -->
<div id="resultControls" class="w-full hidden flex-col items-center gap-3 flex-shrink-0">
<div class="qr-wrapper bg-white p-2 rounded-xl shadow-md flex-shrink-0">
<div id="qrcode" class="w-40 h-40 flex items-center justify-center bg-gray-50"></div>
</div>
<p class="text-xs text-gray-600 text-center flex-shrink-0">掃描 QR Code 下載照片</p>
<div class="btn-row w-full flex flex-col gap-2 mt-1 flex-shrink-0">
<button id="downloadLocalBtn" class="flex-1 py-2.5 bg-gray-800 hover:bg-gray-900 text-white rounded-xl font-bold text-sm transition-all flex items-center justify-center gap-2">
<i data-lucide="download" class="w-4 h-4"></i> 本機下載
</button>
<button id="retakeBtn" class="flex-1 py-2.5 border-2 border-gray-300 hover:border-gray-800 text-gray-800 rounded-xl font-bold text-sm transition-all flex items-center justify-center gap-2">
<i data-lucide="refresh-cw" class="w-4 h-4"></i> 重拍
</button>
</div>
</div>
<canvas id="canvas" class="hidden"></canvas>
</div>
<!-- Loading 遮罩 -->
<div id="loadingOverlay" class="absolute inset-0 bg-black/80 flex flex-col items-center justify-center text-white z-[60] backdrop-blur-sm">
<i data-lucide="loader-2" class="w-14 h-14 animate-spin mb-3 text-indigo-400"></i>
<p class="text-lg font-bold tracking-wider" id="loadingText">正在連線至雲端試算表...</p>
</div>
<!-- iOS 啟用相機提示 -->
<div id="iosPlayHint" class="absolute inset-0 bg-black/80 hidden flex-col items-center justify-center text-white z-[70] backdrop-blur-md cursor-pointer select-none">
<i data-lucide="play-circle" class="w-20 h-20 mb-4 animate-pulse text-pink-400"></i>
<p class="text-2xl font-bold tracking-wider">點擊畫面啟用相機</p>
<p class="text-sm mt-2 text-gray-300">點一下即可開始</p>
</div>
</div>
<!-- 錯誤彈窗 -->
<div id="errorModal" class="fixed inset-0 bg-black/60 hidden items-center justify-center z-[80] p-4 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6">
<div class="flex items-center gap-3 mb-4 text-red-600">
<i data-lucide="alert-triangle" class="w-7 h-7"></i>
<h3 id="errorModalTitle" class="text-xl font-bold">發生錯誤</h3>
</div>
<div id="errorModalMessage" class="text-gray-600 text-sm leading-relaxed mb-6 space-y-2"></div>
<button onclick="closeErrorModal()" class="w-full py-3 bg-gray-800 hover:bg-gray-900 text-white rounded-xl font-bold transition-all">
我知道了
</button>
</div>
</div>
<script>
lucide.createIcons();
/* ─── 工具函式 ─── */
function showErrorModal(title, message) {
document.getElementById('errorModalTitle').innerText = title;
document.getElementById('errorModalMessage').innerHTML = message;
const m = document.getElementById('errorModal');
m.classList.remove('hidden'); m.classList.add('flex');
}
function closeErrorModal() {
const m = document.getElementById('errorModal');
m.classList.add('hidden'); m.classList.remove('flex');
}
/* ─── 常數 ─── */
const SCRIPT_URL = "GAS 網頁佈署後的網址";
/* ─── DOM ─── */
const video = document.getElementById('videoElement');
const canvas = document.getElementById('canvas');
const photoPreview = document.getElementById('photoPreview');
const frameImage = document.getElementById('frameImage');
const captureBtn = document.getElementById('captureBtn');
const retakeBtn = document.getElementById('retakeBtn');
const downloadLocalBtn = document.getElementById('downloadLocalBtn');
const captureControls = document.getElementById('captureControls');
const resultControls = document.getElementById('resultControls');
const frameSelectionArea = document.getElementById('frameSelectionArea');
const qrcodeContainer = document.getElementById('qrcode');
const loadingOverlay = document.getElementById('loadingOverlay');
const iosPlayHint = document.getElementById('iosPlayHint');
const statusText = document.getElementById('statusText');
const loadingText = document.getElementById('loadingText');
const appTitle = document.getElementById('appTitle');
const gestureHint = document.getElementById('gestureHint');
const frameSelector = document.getElementById('frameSelector');
const scrollLeftBtn = document.getElementById('scrollLeftBtn');
const scrollRightBtn = document.getElementById('scrollRightBtn');
/* ─── 狀態 ─── */
let stream = null;
let capturedImageData = null;
let qrCodeInstance = null;
let isCountingDown = false;
let gestureConsecutiveFrames = 0;
let hands = null;
let isDetecting = false;
let mediaPipeFailed = false;
let frameList = [];
let currentFrameIndex = 0;
/* ─── iOS 偵測 ─── */
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
/* ══════════════════════════════════════════
🔄 方向感知佈局調整
(CSS 已處理大部分,JS 處理 dvh 不支援的舊機型)
══════════════════════════════════════════ */
function adjustLayout() {
const isPortrait = window.innerHeight > window.innerWidth;
const isMobile = window.innerWidth < 768;
const cam = document.getElementById('cameraContainer');
const panel = document.getElementById('controlPanel');
if (isMobile && isPortrait) {
// Portrait mobile: camera 62% height, controls rest
cam.style.height = (window.innerHeight * 0.62) + 'px';
cam.style.width = '100%';
cam.style.flex = 'none';
panel.style.width = '100%';
panel.style.flex = '1';
panel.style.height = 'auto';
} else if (isMobile && !isPortrait) {
// Landscape mobile: sidebar 200px
cam.style.height = '100%';
cam.style.width = 'auto';
cam.style.flex = '1';
panel.style.width = '200px';
panel.style.flex = '0 0 200px';
panel.style.height = '100%';
} else {
// Tablet / Desktop: CSS handles it, reset inline styles
cam.style.height = '';
cam.style.width = '';
cam.style.flex = '';
panel.style.width = '';
panel.style.flex = '';
panel.style.height = '';
}
}
window.addEventListener('resize', () => { adjustLayout(); });
// orientationchange fires before dimensions update; delay 300ms
window.addEventListener('orientationchange', () => { setTimeout(adjustLayout, 300); });
// screen.orientation API (more reliable on modern Android)
if (screen.orientation) {
screen.orientation.addEventListener('change', () => { setTimeout(adjustLayout, 300); });
}
/* ══════════════════════════════════════════
CONFIG 讀取
══════════════════════════════════════════ */
async function fetchConfig() {
try {
const response = await fetch(SCRIPT_URL + "?action=getConfig");
if (!response.ok) throw new Error("網路連線失敗,請檢查網址或權限設定。");
const config = await response.json();
if (config.error) throw new Error("後端錯誤:" + config.error);
if (config.title) {
appTitle.innerText = config.title;
document.title = config.title;
}
if (config.frames && config.frames.length > 0) {
frameList = config.frames.map(url => {
let cleanUrl = url.trim();
return (cleanUrl.includes('wsrv.nl') || cleanUrl.startsWith('data:'))
? cleanUrl
: `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl)}`;
});
} else {
throw new Error("試算表中未設定任何相片邊框。");
}
currentFrameIndex = 0;
frameImage.src = frameList[currentFrameIndex];
renderFrameSelector();
hideLoading();
captureControls.classList.remove('hidden');
// 初始佈局計算(等 DOM 穩定後)
setTimeout(adjustLayout, 50);
startCamera();
} catch (error) {
console.error("載入設定失敗:", error);
loadingText.innerHTML = "<span class='text-red-400'>無法載入雲端設定</span>";
showErrorModal("設定讀取失敗", `<p>${error.message}</p>`);
}
}
function hideLoading() {
loadingOverlay.classList.add('hidden');
loadingOverlay.classList.remove('flex');
}
function showLoading(text) {
loadingText.innerText = text || '處理中...';
loadingOverlay.style.background = 'rgba(0,0,0,0.8)';
loadingOverlay.classList.remove('hidden');
loadingOverlay.classList.add('flex');
}
/* ─── 相框選擇器 ─── */
scrollLeftBtn.addEventListener('click', () => {
frameSelector.scrollBy({ left: -130, behavior: 'smooth' });
});
scrollRightBtn.addEventListener('click', () => {
frameSelector.scrollBy({ left: 130, behavior: 'smooth' });
});
function renderFrameSelector() {
frameSelector.innerHTML = '';
if (frameList.length <= 1) {
frameSelectionArea.classList.add('hidden');
return;
}
frameSelectionArea.classList.remove('hidden');
if (frameList.length > 2) {
scrollLeftBtn.classList.remove('hidden');
scrollRightBtn.classList.remove('hidden');
} else {
scrollLeftBtn.classList.add('hidden');
scrollRightBtn.classList.add('hidden');
}
frameList.forEach((url, index) => {
const btn = document.createElement('button');
const isSelected = index === currentFrameIndex;
btn.className = `relative w-16 h-16 sm:w-20 sm:h-20 rounded-2xl border-4 overflow-hidden transition-all duration-300 flex-shrink-0 snap-center ${isSelected ? 'border-indigo-600 scale-110 shadow-xl shadow-indigo-200 z-10' : 'border-white hover:border-indigo-200 opacity-80 hover:opacity-100 shadow-sm'}`;
const bgDiv = document.createElement('div');
bgDiv.className = 'absolute inset-0 bg-checkerboard z-0 opacity-60';
btn.appendChild(bgDiv);
const img = document.createElement('img');
img.src = url;
img.className = 'absolute inset-0 w-full h-full object-contain z-10 drop-shadow-md p-1';
img.crossOrigin = 'anonymous';
btn.appendChild(img);
if (isSelected) {
const checkOverlay = document.createElement('div');
checkOverlay.className = 'absolute bottom-1 right-1 bg-indigo-600 text-white rounded-full p-0.5 z-20 shadow-sm';
checkOverlay.innerHTML = '<i data-lucide="check" class="w-3 h-3"></i>';
btn.appendChild(checkOverlay);
}
btn.onclick = () => {
currentFrameIndex = index;
frameImage.src = frameList[index];
renderFrameSelector();
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
};
frameSelector.appendChild(btn);
});
lucide.createIcons();
}
/* ══════════════════════════════════════════
相機啟動
══════════════════════════════════════════ */
async function startCamera() {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("您的瀏覽器不支援相機!請確認網址開頭為 https:// 或以預設瀏覽器開啟。");
}
video.muted = true;
video.playsInline = true;
video.setAttribute('muted', '');
video.setAttribute('playsinline', '');
video.setAttribute('webkit-playsinline', '');
let constraints = isIOS
? { video: { facingMode: 'user' }, audio: false }
: { video: { facingMode: 'user', width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false };
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
console.warn("相機啟動退回基本設定...", err);
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false });
}
video.srcObject = stream;
let playStarted = false;
const tryPlay = () => { if (playStarted) return; playStarted = true; attemptPlay(); };
video.addEventListener('canplay', tryPlay, { once: true });
setTimeout(tryPlay, 2000);
} catch (err) {
console.error("相機啟動失敗:", err);
statusText.innerHTML = "<span class='text-red-500 font-bold'>相機無法存取。</span>";
showErrorModal("相機存取失敗", `<p>${err.message}</p>`);
}
}
function attemptPlay() {
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise.then(() => {
waitForVideoReady();
}).catch(e => {
console.warn("iOS 自動播放被阻擋:", e);
iosPlayHint.classList.remove('hidden');
iosPlayHint.classList.add('flex');
const unlock = () => {
video.play().then(() => {
iosPlayHint.classList.add('hidden');
iosPlayHint.classList.remove('flex');
waitForVideoReady();
}).catch(err => console.error("解鎖播放失敗:", err));
};
iosPlayHint.addEventListener('touchend', unlock, { once: true });
iosPlayHint.addEventListener('click', unlock, { once: true });
});
}
}
function waitForVideoReady() {
const check = () => {
if (video.videoWidth > 0 && video.videoHeight > 0) {
console.log(`影片尺寸: ${video.videoWidth}x${video.videoHeight}`);
initMediaPipe();
} else {
setTimeout(check, 100);
}
};
check();
}
/* ══════════════════════════════════════════
MediaPipe 初始化
══════════════════════════════════════════ */
function initMediaPipe() {
if (hands) return;
try {
hands = new Hands({
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
});
hands.setOptions({
maxNumHands: 1,
modelComplexity: 0,
minDetectionConfidence: 0.7,
minTrackingConfidence: 0.7
});
hands.onResults(onHandsResults);
hands.send({ image: video })
.then(() => { startDetectionLoop(); })
.catch(err => { console.warn("MediaPipe 初始化失敗:", err); fallbackToManualOnly(); });
} catch (err) {
console.warn("MediaPipe 建構失敗:", err);
fallbackToManualOnly();
}
}
function fallbackToManualOnly() {
mediaPipeFailed = true;
hands = null;
gestureHint.classList.add('hidden');
statusText.innerText = "點擊按鈕拍照!";
}
function startDetectionLoop() {
if (isDetecting) return;
isDetecting = true;
detectFrame();
}
async function detectFrame() {
if (!isDetecting) return;
let consecutiveErrors = 0;
const loop = async () => {
if (!video.paused && !video.ended && hands && video.videoWidth > 0) {
try {
await hands.send({ image: video });
consecutiveErrors = 0;
} catch (err) {
consecutiveErrors++;
if (consecutiveErrors >= 5) {
fallbackToManualOnly();
isDetecting = false;
return;
}
}
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
function isOpenPalm(landmarks) {
return landmarks[8].y < landmarks[5].y
&& landmarks[12].y < landmarks[9].y
&& landmarks[16].y < landmarks[13].y
&& landmarks[20].y < landmarks[17].y;
}
function onHandsResults(results) {
if (isCountingDown || capturedImageData) return;
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
if (isOpenPalm(results.multiHandLandmarks[0])) {
gestureConsecutiveFrames++;
if (gestureConsecutiveFrames > 10) {
gestureConsecutiveFrames = 0;
startCountdown();
}
} else { gestureConsecutiveFrames = 0; }
} else { gestureConsecutiveFrames = 0; }
}
/* ─── 倒數計時 ─── */
function startCountdown() {
if (isCountingDown) return;
isCountingDown = true;
let count = 3;
const overlay = document.getElementById('countdownOverlay');
const countText = document.getElementById('countText');
overlay.classList.remove('hidden');
overlay.classList.add('flex');
countText.innerText = count;
const interval = setInterval(() => {
count--;
if (count > 0) {
countText.innerText = count;
countText.classList.remove('animate-pulse');
void countText.offsetWidth;
countText.classList.add('animate-pulse');
} else {
clearInterval(interval);
overlay.classList.add('hidden');
overlay.classList.remove('flex');
doCapture();
}
}, 1000);
}
captureBtn.addEventListener('click', () => {
if (isCountingDown || capturedImageData) return;
startCountdown();
});
/* ══════════════════════════════════════════
🖼 拍照合成(自動依方向調整比例)
══════════════════════════════════════════ */
function doCapture() {
try {
const cam = document.getElementById('cameraContainer');
// ── 依容器實際尺寸決定輸出解析度 ──
const containerW = cam.clientWidth;
const containerH = cam.clientHeight;
const containerRatio = containerW / containerH;
// 長邊固定 1920px,短邊依比例計算
let targetWidth, targetHeight;
if (containerRatio >= 1) {
targetWidth = 1920;
targetHeight = Math.round(1920 / containerRatio);
} else {
// Portrait: height is the long side
targetHeight = 1920;
targetWidth = Math.round(1920 * containerRatio);
}
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
// 繪製影片(水平鏡射 + object-cover 裁切邏輯)
const videoRatio = video.videoWidth / video.videoHeight;
let vW, vH, vX, vY;
if (videoRatio > containerRatio) {
vH = canvas.height;
vW = video.videoWidth * (canvas.height / video.videoHeight);
vX = (canvas.width - vW) / 2;
vY = 0;
} else {
vW = canvas.width;
vH = video.videoHeight * (canvas.width / video.videoWidth);
vX = 0;
vY = (canvas.height - vH) / 2;
}
ctx.save();
ctx.scale(-1, 1);
ctx.drawImage(video, -vW - vX, vY, vW, vH);
ctx.restore();
// 繪製相框(改為 object-contain 邏輯,確保相框完整不被裁切)
if (frameImage.complete && frameImage.naturalWidth > 0) {
const fRatio = frameImage.naturalWidth / frameImage.naturalHeight;
let fW, fH, fX, fY;
if (fRatio > containerRatio) {
// 相框比容器寬:以容器寬度為基準進行縮放
fW = canvas.width;
fH = canvas.width / fRatio;
fX = 0;
fY = (canvas.height - fH) / 2;
} else {
// 相框比容器高 (直式):以容器高度為基準進行縮放
fH = canvas.height;
fW = canvas.height * fRatio;
fX = (canvas.width - fW) / 2;
fY = 0;
}
ctx.drawImage(frameImage, fX, fY, fW, fH);
}
capturedImageData = canvas.toDataURL('image/jpeg', 1.0);
photoPreview.src = capturedImageData;
photoPreview.classList.remove('hidden');
uploadToGoogleDriveAndGenerateQR();
} catch (err) {
console.error("照片合成錯誤:", err);
isCountingDown = false;
showErrorModal("無法產生照片", "<p>合成照片時發生錯誤,請重新整理後再試。</p>");
}
}
/* ══════════════════════════════════════════
上傳與 QR Code
══════════════════════════════════════════ */
async function uploadToGoogleDriveAndGenerateQR() {
captureControls.classList.add('hidden');
frameSelectionArea.classList.add('hidden');
showLoading("正在上傳至 Google 雲端硬碟...");
statusText.innerText = "上傳中...";
try {
const uniqueFileName = `photo_${Date.now()}.jpg`;
const response = await fetch(SCRIPT_URL, {
method: 'POST',
body: JSON.stringify({ image: capturedImageData, filename: uniqueFileName })
});
const result = await response.json();
if (result.success) {
hideLoading();
resultControls.classList.remove('hidden');
resultControls.classList.add('flex');
statusText.innerText = "照片已儲存至雲端硬碟!";
qrcodeContainer.innerHTML = '';
const isPortrait = window.innerHeight > window.innerWidth;
const isMobile = window.innerWidth < 768;
const qrSize = (isPortrait && isMobile) ? 120 : 150;
qrCodeInstance = new QRCode(qrcodeContainer, {
text: result.url,
width: qrSize,
height: qrSize,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H
});
} else {
throw new Error(result.error);
}
} catch (error) {
console.error("上傳失敗:", error);
hideLoading();
captureControls.classList.remove('hidden');
if (frameList.length > 1) frameSelectionArea.classList.remove('hidden');
photoPreview.classList.add('hidden');
capturedImageData = null;
isCountingDown = false;
showErrorModal("上傳失敗", "<p>上傳發生錯誤,請檢查網路連線或稍後再試。</p>");
statusText.innerHTML = "<span class='text-red-500 font-bold'>上傳失敗,請重試。</span>";
}
}
/* ─── 下載與重拍 ─── */
downloadLocalBtn.addEventListener('click', () => {
if (!capturedImageData) return;
const a = document.createElement('a');
a.href = capturedImageData;
a.download = `photo-${Date.now()}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
retakeBtn.addEventListener('click', () => {
photoPreview.classList.add('hidden');
photoPreview.src = '';
capturedImageData = null;
isCountingDown = false;
gestureConsecutiveFrames = 0;
resultControls.classList.add('hidden');
resultControls.classList.remove('flex');
captureControls.classList.remove('hidden');
if (frameList.length > 1) frameSelectionArea.classList.remove('hidden');
statusText.innerText = mediaPipeFailed ? "點擊按鈕拍照!" : "點擊下方按鈕拍照!";
});
/* ─── 啟動 ─── */
adjustLayout(); // 初始計算
fetchConfig();
</script>
</body>
</html>
快來試試吧~~

沒有留言:
張貼留言
歡迎大家一起留言討論!