2026-07-07 10:04:27 +08:00
|
|
|
|
<!doctype html>
|
|
|
|
|
|
<html lang="zh-Hant">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
|
|
|
|
<title>動態相片</title>
|
2026-07-28 11:41:20 +08:00
|
|
|
|
<script>
|
|
|
|
|
|
// iOS 27 Beta 的 WebKit 會讓 tfjs 打包著色器連結失敗
|
|
|
|
|
|
// (Failed to link vertex and fragment shaders,辨識引擎無聲掛掉),
|
|
|
|
|
|
// 改用未打包著色器可正常運作。必須在 MindAR 載入前把 tfjsflags
|
|
|
|
|
|
// 補進網址,tfjs 是從 query string 讀取這個設定的;
|
|
|
|
|
|
// 網址已手動帶 tfjsflags 時不覆蓋,方便之後實驗其他參數
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent) ||
|
|
|
|
|
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
|
|
|
|
if (isIOS && !location.search.includes('tfjsflags')) {
|
|
|
|
|
|
const q = location.search ? location.search + '&' : '?';
|
|
|
|
|
|
history.replaceState(null, '', q + 'tfjsflags=WEBGL_PACK:false' + location.hash);
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
2026-07-07 10:04:27 +08:00
|
|
|
|
<script src="/vendor/aframe.min.js"></script>
|
|
|
|
|
|
<script src="/vendor/mindar-image-aframe.prod.js"></script>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
/* 背景只能放在 html(root canvas),不能放在 body:
|
|
|
|
|
|
MindAR 的相機 video 是 body 下 z-index:-2 的元素,body 自己的背景會蓋住它 */
|
|
|
|
|
|
html { background: #000; }
|
|
|
|
|
|
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
|
|
|
|
|
|
#overlay {
|
|
|
|
|
|
position: fixed; inset: 0; z-index: 1000;
|
|
|
|
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
|
|
|
|
gap: 16px; padding: 24px; text-align: center;
|
|
|
|
|
|
background: radial-gradient(circle at 50% 30%, #1c2333, #0b0e16 70%);
|
|
|
|
|
|
color: #e8ecf5; font-family: system-ui, -apple-system, "Noto Sans TC", sans-serif;
|
|
|
|
|
|
}
|
|
|
|
|
|
#overlay.hidden { display: none; }
|
|
|
|
|
|
#overlay h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: 2px; }
|
|
|
|
|
|
#status { margin: 0; font-size: 15px; color: #9aa4bd; min-height: 1.4em; }
|
|
|
|
|
|
#startBtn {
|
|
|
|
|
|
padding: 14px 48px; font-size: 18px; font-weight: 600; letter-spacing: 4px;
|
|
|
|
|
|
color: #0b0e16; background: #e8ecf5; border: none; border-radius: 999px; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
#startBtn:active { transform: scale(0.97); }
|
|
|
|
|
|
.hint { font-size: 13px; color: #6b7590; max-width: 320px; line-height: 1.7; }
|
|
|
|
|
|
#stopBtn {
|
|
|
|
|
|
position: fixed; top: 16px; right: 16px; z-index: 1001;
|
|
|
|
|
|
width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
|
|
|
|
|
|
background: rgba(0,0,0,.5); color: #fff; font-size: 18px; display: none;
|
|
|
|
|
|
}
|
2026-07-07 10:27:39 +08:00
|
|
|
|
#dlBtn {
|
|
|
|
|
|
position: fixed; bottom: 24px; right: 16px; z-index: 1001;
|
|
|
|
|
|
width: 52px; height: 52px; border-radius: 50%;
|
|
|
|
|
|
background: rgba(0,0,0,.55); color: #fff; font-size: 24px;
|
|
|
|
|
|
display: none; align-items: center; justify-content: center;
|
|
|
|
|
|
text-decoration: none; box-shadow: 0 2px 8px rgba(0,0,0,.4);
|
|
|
|
|
|
}
|
|
|
|
|
|
#dlBtn:active { transform: scale(0.94); }
|
2026-07-07 10:04:27 +08:00
|
|
|
|
a { color: #8fb4ff; }
|
2026-07-28 11:41:20 +08:00
|
|
|
|
#errBox {
|
|
|
|
|
|
position: fixed; top: 0; left: 0; right: 0; z-index: 2000;
|
|
|
|
|
|
display: none; padding: 10px 14px;
|
|
|
|
|
|
background: rgba(170, 32, 32, .92); color: #fff;
|
|
|
|
|
|
font: 12px/1.6 monospace; white-space: pre-wrap; word-break: break-all;
|
|
|
|
|
|
max-height: 45%; overflow: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
#dbgBox {
|
|
|
|
|
|
position: fixed; bottom: 0; left: 0; z-index: 2000;
|
|
|
|
|
|
display: none; padding: 8px 12px; max-width: 100%;
|
|
|
|
|
|
background: rgba(0, 0, 0, .6); color: #9f9;
|
|
|
|
|
|
font: 11px/1.5 monospace; white-space: pre-wrap; word-break: break-all;
|
|
|
|
|
|
max-height: 40%; overflow: auto;
|
|
|
|
|
|
}
|
2026-07-07 10:04:27 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div id="overlay">
|
|
|
|
|
|
<h1>動態相片</h1>
|
|
|
|
|
|
<p id="status">載入中…</p>
|
|
|
|
|
|
<button id="startBtn" hidden>開始掃描</button>
|
|
|
|
|
|
<p class="hint">按下開始後,將相機對準已登錄的照片,<br>對應的影片就會覆蓋在照片上播放。</p>
|
2026-07-28 11:41:20 +08:00
|
|
|
|
<p class="hint" style="font-size:11px">v8</p>
|
2026-07-07 10:04:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<button id="stopBtn" title="停止">✕</button>
|
2026-07-07 10:27:39 +08:00
|
|
|
|
<a id="dlBtn" title="下載影片" download>⬇</a>
|
2026-07-28 11:41:20 +08:00
|
|
|
|
<div id="errBox"></div>
|
|
|
|
|
|
<div id="dbgBox"></div>
|
2026-07-07 10:04:27 +08:00
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
const overlay = document.getElementById('overlay');
|
|
|
|
|
|
const statusEl = document.getElementById('status');
|
|
|
|
|
|
const startBtn = document.getElementById('startBtn');
|
|
|
|
|
|
const stopBtn = document.getElementById('stopBtn');
|
2026-07-07 10:27:39 +08:00
|
|
|
|
const dlBtn = document.getElementById('dlBtn');
|
2026-07-28 11:41:20 +08:00
|
|
|
|
const errBox = document.getElementById('errBox');
|
|
|
|
|
|
const dbgBox = document.getElementById('dbgBox');
|
|
|
|
|
|
|
|
|
|
|
|
// 掃描在某些裝置上「沒反應」時,錯誤常常只進 console 看不到。
|
|
|
|
|
|
// 這裡把所有未攔截的錯誤直接顯示在畫面頂端,行動裝置也能看到
|
|
|
|
|
|
function showErr(msg) {
|
|
|
|
|
|
errBox.style.display = 'block';
|
|
|
|
|
|
errBox.textContent += (errBox.textContent ? '\n' : '') + msg;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('error', (e) => {
|
|
|
|
|
|
showErr('錯誤:' + (e.message || String(e.error || '未知')));
|
|
|
|
|
|
});
|
|
|
|
|
|
window.addEventListener('unhandledrejection', (e) => {
|
|
|
|
|
|
const r = e.reason;
|
|
|
|
|
|
showErr('錯誤:' + ((r && r.message) || String(r)));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 網址加 ?debug 會在畫面底部顯示掃描過程(相機解析度、targetFound 等)
|
|
|
|
|
|
const DEBUG = new URLSearchParams(location.search).has('debug');
|
|
|
|
|
|
function dbg(msg) {
|
|
|
|
|
|
if (!DEBUG) return;
|
|
|
|
|
|
dbgBox.style.display = 'block';
|
|
|
|
|
|
const t = (performance.now() / 1000).toFixed(1);
|
|
|
|
|
|
dbgBox.textContent += `[${t}s] ${msg}\n`;
|
|
|
|
|
|
dbgBox.scrollTop = dbgBox.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// debug 模式下把 console 訊息也收進畫面:
|
|
|
|
|
|
// tfjs 著色器編譯失敗的詳細原因只會 console.log,行動裝置上否則看不到
|
|
|
|
|
|
if (DEBUG) {
|
|
|
|
|
|
for (const level of ['log', 'warn', 'error']) {
|
|
|
|
|
|
const orig = console[level].bind(console);
|
|
|
|
|
|
console[level] = (...args) => {
|
|
|
|
|
|
orig(...args);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const text = args.map((a) => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
|
|
|
|
|
if (text && !text.startsWith('%c')) dbg(`console.${level}: ${text.slice(0, 400)}`);
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-07 10:04:27 +08:00
|
|
|
|
|
|
|
|
|
|
let sceneEl = null;
|
|
|
|
|
|
const videos = [];
|
|
|
|
|
|
|
|
|
|
|
|
async function init() {
|
|
|
|
|
|
let state;
|
|
|
|
|
|
try {
|
|
|
|
|
|
state = await (await fetch('/api/state')).json();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
statusEl.textContent = '無法連線到伺服器';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const adminUrl = `http://${location.hostname}:${state.ports.admin}/`;
|
|
|
|
|
|
if (!state.targets || !state.targets.targets.length) {
|
|
|
|
|
|
statusEl.innerHTML = `尚未建立任何動態相片。<br>請先到 <a href="${adminUrl}">管理頁</a> 新增照片與影片並編譯。`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!window.isSecureContext) {
|
|
|
|
|
|
statusEl.innerHTML = '瀏覽器要求安全連線才能開啟相機,<br>請改用 <b>https://</b> 連到本頁。';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pairById = Object.fromEntries(state.pairs.map(p => [p.id, p]));
|
|
|
|
|
|
const entries = state.targets.targets
|
|
|
|
|
|
.map((t, index) => ({ ...t, index, pair: pairById[t.pairId] }))
|
|
|
|
|
|
.filter(t => t.pair);
|
|
|
|
|
|
|
|
|
|
|
|
if (!entries.length) {
|
|
|
|
|
|
statusEl.innerHTML = `目標檔已過期,請到 <a href="${adminUrl}">管理頁</a> 重新編譯。`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 11:41:20 +08:00
|
|
|
|
dbg(`state 載入完成:${entries.length} 個目標,編譯於 ${new Date(state.targets.compiledAt).toLocaleString()}`);
|
2026-07-07 10:04:27 +08:00
|
|
|
|
buildScene(state.targets.compiledAt, entries);
|
2026-07-07 10:05:51 +08:00
|
|
|
|
statusEl.textContent = '';
|
2026-07-07 10:04:27 +08:00
|
|
|
|
startBtn.hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildScene(version, entries) {
|
|
|
|
|
|
sceneEl = document.createElement('a-scene');
|
|
|
|
|
|
sceneEl.setAttribute('mindar-image',
|
|
|
|
|
|
`imageTargetSrc: /data/targets.mind?v=${version}; autoStart: false; maxTrack: 1; ` +
|
2026-07-07 10:05:51 +08:00
|
|
|
|
'filterMinCF: 0.0001; filterBeta: 0.001; uiLoading: no; uiScanning: no; uiError: yes;');
|
2026-07-07 10:04:27 +08:00
|
|
|
|
sceneEl.setAttribute('embedded', '');
|
|
|
|
|
|
sceneEl.setAttribute('color-space', 'sRGB');
|
|
|
|
|
|
sceneEl.setAttribute('renderer', 'colorManagement: true; physicallyCorrectLights: true');
|
|
|
|
|
|
sceneEl.setAttribute('vr-mode-ui', 'enabled: false');
|
|
|
|
|
|
sceneEl.setAttribute('device-orientation-permission-ui', 'enabled: false');
|
|
|
|
|
|
|
|
|
|
|
|
const assets = document.createElement('a-assets');
|
|
|
|
|
|
sceneEl.appendChild(assets);
|
|
|
|
|
|
|
|
|
|
|
|
const camera = document.createElement('a-camera');
|
|
|
|
|
|
camera.setAttribute('position', '0 0 0');
|
|
|
|
|
|
camera.setAttribute('look-controls', 'enabled: false');
|
|
|
|
|
|
sceneEl.appendChild(camera);
|
|
|
|
|
|
|
|
|
|
|
|
for (const t of entries) {
|
|
|
|
|
|
const video = document.createElement('video');
|
|
|
|
|
|
video.id = 'vid-' + t.index;
|
|
|
|
|
|
video.src = '/data/videos/' + encodeURIComponent(t.pair.video);
|
|
|
|
|
|
video.loop = true;
|
|
|
|
|
|
video.preload = 'auto';
|
|
|
|
|
|
video.crossOrigin = 'anonymous';
|
|
|
|
|
|
video.setAttribute('playsinline', '');
|
|
|
|
|
|
video.setAttribute('webkit-playsinline', '');
|
|
|
|
|
|
// 影片一律以靜音狀態建立,直到第一次掃到照片才解除靜音,
|
|
|
|
|
|
// 確保解鎖過程無論發生什麼載入競態都不可能出聲
|
|
|
|
|
|
video.muted = true;
|
|
|
|
|
|
video.defaultMuted = true;
|
|
|
|
|
|
video.setAttribute('muted', '');
|
|
|
|
|
|
assets.appendChild(video);
|
|
|
|
|
|
videos.push(video);
|
|
|
|
|
|
|
|
|
|
|
|
const anchor = document.createElement('a-entity');
|
|
|
|
|
|
anchor.setAttribute('mindar-image-target', 'targetIndex: ' + t.index);
|
|
|
|
|
|
|
|
|
|
|
|
// MindAR 的目標寬度固定為 1,用照片長寬比讓影片剛好蓋住整張照片
|
|
|
|
|
|
const plane = document.createElement('a-video');
|
|
|
|
|
|
plane.setAttribute('src', '#vid-' + t.index);
|
|
|
|
|
|
plane.setAttribute('width', '1');
|
|
|
|
|
|
plane.setAttribute('height', String(t.aspect || 1));
|
|
|
|
|
|
plane.setAttribute('position', '0 0 0');
|
|
|
|
|
|
anchor.appendChild(plane);
|
|
|
|
|
|
|
|
|
|
|
|
let isFound = false;
|
|
|
|
|
|
// 保險:照片不在畫面上時若影片意外開始播放,立刻壓回去
|
|
|
|
|
|
video.addEventListener('playing', () => {
|
|
|
|
|
|
if (!isFound) {
|
|
|
|
|
|
video.pause();
|
|
|
|
|
|
video.currentTime = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
anchor.addEventListener('targetFound', () => {
|
2026-07-28 11:41:20 +08:00
|
|
|
|
dbg(`targetFound:#${t.index}(${t.pair.name})`);
|
2026-07-07 10:04:27 +08:00
|
|
|
|
isFound = true;
|
|
|
|
|
|
video.muted = false; // 掃到照片這一刻才解除靜音
|
|
|
|
|
|
video.defaultMuted = false;
|
|
|
|
|
|
video.removeAttribute('muted');
|
|
|
|
|
|
video.play().catch(() => {
|
|
|
|
|
|
video.muted = true; // 有聲播放被瀏覽器擋下時退回靜音播放
|
|
|
|
|
|
video.play().catch(() => {});
|
|
|
|
|
|
});
|
2026-07-07 10:27:39 +08:00
|
|
|
|
// 右下角出現下載按鈕,可下載最後掃到的影片
|
|
|
|
|
|
dlBtn.href = video.src;
|
|
|
|
|
|
dlBtn.setAttribute('download', t.pair.name + t.pair.video.slice(t.pair.video.lastIndexOf('.')));
|
|
|
|
|
|
dlBtn.style.display = 'flex';
|
2026-07-07 10:04:27 +08:00
|
|
|
|
});
|
|
|
|
|
|
anchor.addEventListener('targetLost', () => {
|
2026-07-28 11:41:20 +08:00
|
|
|
|
dbg(`targetLost:#${t.index}`);
|
2026-07-07 10:04:27 +08:00
|
|
|
|
isFound = false;
|
|
|
|
|
|
video.pause();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
sceneEl.appendChild(anchor);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(sceneEl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
startBtn.addEventListener('click', async () => {
|
|
|
|
|
|
startBtn.disabled = true;
|
|
|
|
|
|
statusEl.textContent = '啟動相機中…';
|
|
|
|
|
|
|
|
|
|
|
|
// 在使用者手勢中先 play/pause 一次,解鎖行動裝置的自動播放限制
|
|
|
|
|
|
// (解鎖許可綁定各個影片元素,之後掃到照片才能自動帶聲音播放)。
|
|
|
|
|
|
// 影片維持靜音,直到 targetFound 才解除,解鎖過程絕不出聲
|
|
|
|
|
|
for (const v of videos) {
|
|
|
|
|
|
// 重新靜音(之前掃到過照片的影片會處於未靜音狀態)
|
|
|
|
|
|
v.muted = true;
|
|
|
|
|
|
v.defaultMuted = true;
|
|
|
|
|
|
v.setAttribute('muted', '');
|
|
|
|
|
|
try {
|
|
|
|
|
|
await v.play();
|
|
|
|
|
|
v.pause();
|
|
|
|
|
|
v.currentTime = 0;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 靜音播放被擋下也無妨,掃到照片時會再嘗試播放
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 保險:AR 引擎就緒時把所有影片壓回暫停歸零,任何啟動期間的意外播放都會被停掉
|
|
|
|
|
|
sceneEl.addEventListener('arReady', () => {
|
|
|
|
|
|
for (const v of videos) {
|
|
|
|
|
|
if (!v.paused) v.pause();
|
|
|
|
|
|
v.currentTime = 0;
|
|
|
|
|
|
}
|
2026-07-28 11:41:20 +08:00
|
|
|
|
// MindAR 的相機 video 是掛在 body 下、沒有 id 的那個元素
|
|
|
|
|
|
const cam = document.querySelector('body > video');
|
|
|
|
|
|
dbg('arReady:辨識引擎啟動' + (cam ? `,相機 ${cam.videoWidth}x${cam.videoHeight}` : ''));
|
|
|
|
|
|
}, { once: true });
|
|
|
|
|
|
sceneEl.addEventListener('arError', () => {
|
|
|
|
|
|
showErr('AR 啟動失敗(arError):通常是相機無法開啟或 WebGL 資源不足');
|
2026-07-07 10:04:27 +08:00
|
|
|
|
}, { once: true });
|
|
|
|
|
|
|
|
|
|
|
|
const start = () => {
|
|
|
|
|
|
const sys = sceneEl.systems['mindar-image-system'];
|
|
|
|
|
|
sys.start();
|
|
|
|
|
|
overlay.classList.add('hidden');
|
|
|
|
|
|
stopBtn.style.display = 'block';
|
|
|
|
|
|
};
|
|
|
|
|
|
if (sceneEl.hasLoaded) start();
|
|
|
|
|
|
else sceneEl.addEventListener('loaded', start, { once: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
stopBtn.addEventListener('click', () => {
|
|
|
|
|
|
try { sceneEl.systems['mindar-image-system'].stop(); } catch {}
|
|
|
|
|
|
for (const v of videos) v.pause();
|
|
|
|
|
|
stopBtn.style.display = 'none';
|
2026-07-07 10:27:39 +08:00
|
|
|
|
dlBtn.style.display = 'none';
|
2026-07-07 10:04:27 +08:00
|
|
|
|
overlay.classList.remove('hidden');
|
|
|
|
|
|
startBtn.disabled = false;
|
|
|
|
|
|
statusEl.textContent = '已停止,可再次開始掃描';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
init();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|