Files
photo-live/public/index.html
T
JianMiauandClaude Fable 5 0cf034a856 新增動態相片 Web AR 服務(照片掃描播放對應影片)
摘要:
建立照片=key、影片=value 的 Web AR 服務,手機瀏覽器掃描照片後,
影片會貼齊照片位置覆蓋播放(同小米相片印表機動態照片效果)。

根本原因:
全新專案。

影響:
管理頁(3510/HTTP)可上傳配對並於瀏覽器端編譯 MindAR 特徵檔;
掃描頁(3511/HTTPS)供手機掃描照片播放影片。資料存於 /data volume,
支援外掛正式憑證(/certs),無憑證時自動產生自簽憑證。

修法:
Node.js + Express 後端、MindAR + A-Frame 前端,Docker 部署
(sudo docker compose up -d --build)。已處理 iOS 影片解鎖漏音
(影片保持靜音直到掃到照片)、MindAR 相機畫面被 body 背景遮蔽、
A-Frame boolean 屬性解析等問題。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:04:27 +08:00

218 lines
7.9 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 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;
}
a { color: #8fb4ff; }
</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">v5</p>
</div>
<button id="stopBtn" title="停止"></button>
<script>
const overlay = document.getElementById('overlay');
const statusEl = document.getElementById('status');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
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;
}
buildScene(state.targets.compiledAt, entries);
statusEl.textContent = `已載入 ${entries.length} 張動態相片`;
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: yes; 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', () => {
isFound = true;
video.muted = false; // 掃到照片這一刻才解除靜音
video.defaultMuted = false;
video.removeAttribute('muted');
video.play().catch(() => {
video.muted = true; // 有聲播放被瀏覽器擋下時退回靜音播放
video.play().catch(() => {});
});
});
anchor.addEventListener('targetLost', () => {
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;
}
}, { 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';
overlay.classList.remove('hidden');
startBtn.disabled = false;
statusEl.textContent = '已停止,可再次開始掃描';
});
init();
</script>
</body>
</html>