Files
photo-live/public/index.html
T
JianMiauandClaude Fable 5 77fff4326c 修正 iOS 27 Beta 掃描無反應,掃描頁加入畫面內錯誤與除錯顯示(v8)
根本原因:
iOS 27 Beta 的 WebKit 回歸使 tfjs 打包著色器連結失敗
(Failed to link vertex and fragment shaders),MindAR 辨識引擎
啟動時無聲掛掉;相機畫面只是一般 video 元素照常顯示,
使用者看到的症狀就是「掃描完全沒反應」。錯誤只進 console,
行動裝置上看不到,導致先前無從排查。

影響:
iOS 27 Beta 裝置無論掃哪張照片都不會觸發辨識;iOS 26 與桌面正常。

修法:
1. 在 MindAR 載入前偵測 iOS 裝置,自動把 tfjsflags=WEBGL_PACK:false
   補進網址(tfjs 由 query string 讀取設定),改用未打包著色器,
   已在 iOS 27 Beta 實機驗證可正常辨識;網址手動帶 tfjsflags 時不覆蓋。
2. 未攔截的 JS 錯誤改以紅色橫幅直接顯示在畫面頂端;網址加 ?debug
   可在畫面底部看到掃描過程(目標數、相機解析度、targetFound/Lost、
   console 訊息),行動裝置也能自行排查。版本字樣升至 v8。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:41:20 +08:00

314 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<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>
<script src="/vendor/aframe.min.js"></script>
<script src="/vendor/mindar-image-aframe.prod.js"></script>
<style>
/* 背景只能放在 htmlroot 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;
}
#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); }
a { color: #8fb4ff; }
#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;
}
</style>
</head>
<body>
<div id="overlay">
<h1>動態相片</h1>
<p id="status">載入中…</p>
<button id="startBtn" hidden>開始掃描</button>
<p class="hint">按下開始後,將相機對準已登錄的照片,<br>對應的影片就會覆蓋在照片上播放。</p>
<p class="hint" style="font-size:11px">v8</p>
</div>
<button id="stopBtn" title="停止"></button>
<a id="dlBtn" title="下載影片" download></a>
<div id="errBox"></div>
<div id="dbgBox"></div>
<script>
const overlay = document.getElementById('overlay');
const statusEl = document.getElementById('status');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const dlBtn = document.getElementById('dlBtn');
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 {}
};
}
}
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;
}
dbg(`state 載入完成:${entries.length} 個目標,編譯於 ${new Date(state.targets.compiledAt).toLocaleString()}`);
buildScene(state.targets.compiledAt, entries);
statusEl.textContent = '';
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; ` +
'filterMinCF: 0.0001; filterBeta: 0.001; uiLoading: no; uiScanning: no; uiError: yes;');
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', () => {
dbg(`targetFound#${t.index}${t.pair.name}`);
isFound = true;
video.muted = false; // 掃到照片這一刻才解除靜音
video.defaultMuted = false;
video.removeAttribute('muted');
video.play().catch(() => {
video.muted = true; // 有聲播放被瀏覽器擋下時退回靜音播放
video.play().catch(() => {});
});
// 右下角出現下載按鈕,可下載最後掃到的影片
dlBtn.href = video.src;
dlBtn.setAttribute('download', t.pair.name + t.pair.video.slice(t.pair.video.lastIndexOf('.')));
dlBtn.style.display = 'flex';
});
anchor.addEventListener('targetLost', () => {
dbg(`targetLost#${t.index}`);
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;
}
// 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 資源不足');
}, { 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';
dlBtn.style.display = 'none';
overlay.classList.remove('hidden');
startBtn.disabled = false;
statusEl.textContent = '已停止,可再次開始掃描';
});
init();
</script>
</body>
</html>