2026年6月8日 星期一

Gemini - 僑信拍貼機

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



🎯測試用 Chroembook 拍貼機:https://gemini.google.com/share/89b870dc96e3
🎯測試用拍貼機上傳資料夾(拍照測試後記得到雲端硬碟刪除):

Hello everyone! 
Today, I am absolutely thrilled to share a fun and exciting project with you all: a DIY Interactive Photo Booth! With just a Chromebook, some coding assistance from Gemini Canvas, and Google Apps Script, I’ve managed to turn a simple device into a fully functional, smart photo booth.

大家好!今天我非常興奮能和大家分享一個有趣又好玩的專案:自製互動拍貼機!只要一台 Chromebook,加上 Gemini Canvas 的程式碼輔助以及 Google Apps Script,我成功地把一台簡單的裝置變成了功能齊全的智慧拍貼機。
 
Let me walk you through some of its coolest features. First of all, it's completely touch-free when taking a picture! You don't need to press any buttons or set a timer manually. All you have to do is stand in front of the camera and raise your hand, showing a "high-five" gesture. The AI detects your hand and automatically starts the countdown timer. It's incredibly intuitive and fun to use.

讓我為各位介紹它最酷的幾個特色。首先,拍照時完全不需要觸控螢幕!你不需要按任何按鈕或手動設定計時器。你只需要站在鏡頭前,舉起手比出一個「五」(🖐)的手勢。AI 就會偵測到你的手勢,並自動啟動自拍倒數。這非常直覺,而且玩起來樂趣十足。
 
Secondly, we all know a photo booth isn't complete without fun frames. This system allows you to easily switch between different custom borders to fit any event or theme. The best part is that the settings are managed through a simple Google Sheet, so updating the frames is as easy as pasting a new image link.

其次,我們都知道,沒有有趣邊框的拍貼機就不算完整。這個系統可以讓你輕鬆更換不同的自訂邊框,以搭配任何活動或主題。最棒的是,所有設定都是透過一個簡單的 Google 試算表來管理,所以更新邊框就像貼上新的圖片連結一樣簡單。
 
But what happens after you take the perfect shot? That’s where the seamless cloud integration comes in. Once the photo is snapped, the system instantly uploads the file to a designated Google Drive folder. Immediately after, a QR Code pops up right on the screen. Anyone can just scan that QR Code with their smartphone and download their photo instantly. No need for cables or waiting!

但是拍下完美照片後會怎樣呢?這就是無縫雲端整合發揮作用的地方了。照片拍好後,系統會立刻將檔案自動上傳到指定的 Google 雲端硬碟資料夾。緊接著,螢幕上會彈出一個 QR Code。任何人只要用智慧型手機掃描那個 QR Code,就能立刻下載照片。不需要傳輸線,也完全不用等!
 
Building this was an amazing experience, blending basic web development with Google's powerful cloud tools and AI gesture recognition. It's a perfect example of how we can use everyday technology to create interactive and joyful experiences. I have the demo set up right here, and I highly encourage everyone to come up, give a high-five to the camera, and take a photo home! Thank you!

製作這台機器的經驗非常棒,它結合了基礎網頁開發、Google 強大的雲端工具以及 AI 手勢辨識技術。這是一個絕佳的範例,展現了我們如何利用日常科技來創造充滿互動與歡樂的體驗。我已經把示範機台架設在這裡了,非常鼓勵大家上前來,對著鏡頭比個「五」,帶張照片回家吧!謝謝大家!
 
一、建立一個 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>



快來試試吧~~



沒有留言:

張貼留言

歡迎大家一起留言討論!