2026年6月8日 星期一

Gemini - 僑信拍貼機

用 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>



快來試試吧~~



沒有留言:

張貼留言

歡迎大家一起留言討論!