新增動態相片 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>
This commit is contained in:
2026-07-07 10:04:27 +08:00
co-authored by Claude Fable 5
commit 0cf034a856
10 changed files with 1899 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>動態相片管理</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px; font-family: system-ui, -apple-system, "Noto Sans TC", sans-serif;
background: #f2f4f8; color: #1c2333;
}
@media (prefers-color-scheme: dark) {
body { background: #0f1219; color: #e8ecf5; }
.card { background: #1a1f2b !important; }
th { color: #9aa4bd !important; }
input[type="text"] { background: #0f1219; color: #e8ecf5; border-color: #333c50 !important; }
}
h1 { font-size: 22px; margin: 0 0 4px; }
.sub { color: #6b7590; font-size: 14px; margin: 0 0 24px; }
.card {
background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,.08); max-width: 860px;
}
.card h2 { font-size: 16px; margin: 0 0 14px; }
form { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: #6b7590; }
input[type="text"] { padding: 8px 10px; border: 1px solid #d4dae6; border-radius: 8px; font-size: 14px; width: 180px; }
input[type="file"] { font-size: 13px; max-width: 230px; }
button {
padding: 9px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600;
cursor: pointer; background: #2b6cf5; color: #fff;
}
button:disabled { opacity: .5; cursor: default; }
button.danger { background: transparent; color: #d33; padding: 4px 8px; font-weight: 400; }
button.big { padding: 12px 28px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; font-size: 12px; color: #6b7590; font-weight: 500; padding: 6px 8px; }
td { padding: 8px; border-top: 1px solid rgba(128,128,128,.15); vertical-align: middle; }
td img { width: 72px; height: 54px; object-fit: cover; border-radius: 6px; display: block; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge.warn { background: #fff3d6; color: #8a6100; }
.badge.ok { background: #d9f2e2; color: #12703c; }
@media (prefers-color-scheme: dark) {
.badge.warn { background: #443a14; color: #ffd45e; }
.badge.ok { background: #10331f; color: #5fd68f; }
}
#msg { font-size: 14px; margin-top: 10px; min-height: 1.4em; }
#msg.err { color: #d33; }
.muted { color: #6b7590; font-size: 13px; }
progress { width: 240px; }
</style>
</head>
<body>
<h1>動態相片管理</h1>
<p class="sub">照片=key、影片=value。新增或刪除配對後,記得按「重新編譯」產生辨識特徵檔。掃描頁:<a id="viewerLink" href="#">載入中…</a></p>
<div class="card">
<h2>新增配對</h2>
<form id="addForm">
<label>照片(jpg / png<input type="file" name="photo" accept=".jpg,.jpeg,.png" required></label>
<label>影片(mp4 建議 H.264<input type="file" name="video" accept=".mp4,.m4v,.mov,.webm" required></label>
<button type="submit" id="addBtn">上傳</button>
</form>
<div id="msg"></div>
</div>
<div class="card">
<h2>辨識特徵檔 <span id="compileBadge"></span></h2>
<p class="muted">編譯在你目前的瀏覽器執行(建議用桌機,每張照片約 10~30 秒),完成後自動上傳到伺服器。</p>
<button id="compileBtn" class="big">重新編譯全部照片</button>
<span id="compileStatus" class="muted" style="margin-left:12px"></span>
<div><progress id="compileProgress" max="100" value="0" hidden></progress></div>
</div>
<div class="card">
<h2>已登錄配對(<span id="count">0</span></h2>
<table>
<thead><tr><th>照片</th><th>名稱</th><th>影片</th><th>建立時間</th><th></th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<script>
let state = null;
async function refresh() {
state = await (await fetch('/api/state')).json();
document.getElementById('count').textContent = state.pairs.length;
const viewerUrl = `https://${location.hostname}:${state.ports.viewer}/`;
const viewerLink = document.getElementById('viewerLink');
viewerLink.href = viewerUrl;
viewerLink.textContent = viewerUrl;
const badge = document.getElementById('compileBadge');
if (state.needsCompile) {
badge.className = 'badge warn';
badge.textContent = '有變更尚未編譯';
} else if (state.targets && state.targets.targets.length) {
badge.className = 'badge ok';
badge.textContent = '已是最新(' + new Date(state.targets.compiledAt).toLocaleString() + '';
} else {
badge.className = 'badge warn';
badge.textContent = '尚未編譯';
}
const tbody = document.getElementById('rows');
tbody.textContent = '';
for (const p of state.pairs) {
const tr = document.createElement('tr');
const tdImg = document.createElement('td');
const img = document.createElement('img');
img.src = '/data/photos/' + encodeURIComponent(p.photo);
tdImg.appendChild(img);
const tdName = document.createElement('td');
tdName.textContent = p.name;
const tdVideo = document.createElement('td');
const a = document.createElement('a');
a.href = '/data/videos/' + encodeURIComponent(p.video);
a.target = '_blank';
a.textContent = p.video;
tdVideo.appendChild(a);
const tdTime = document.createElement('td');
tdTime.className = 'muted';
tdTime.textContent = new Date(p.createdAt).toLocaleString();
const tdDel = document.createElement('td');
const del = document.createElement('button');
del.className = 'danger';
del.textContent = '刪除';
del.onclick = async () => {
if (!confirm('確定刪除「' + p.name + '」?照片與影片檔案將一併移除。')) return;
await fetch('/api/pairs/' + p.id, { method: 'DELETE' });
refresh();
};
tdDel.appendChild(del);
tr.append(tdImg, tdName, tdVideo, tdTime, tdDel);
tbody.appendChild(tr);
}
}
document.getElementById('addForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('addBtn');
const msg = document.getElementById('msg');
btn.disabled = true;
msg.className = '';
msg.textContent = '上傳中…';
try {
const res = await fetch('/api/pairs', { method: 'POST', body: new FormData(e.target) });
const json = await res.json();
if (!res.ok) throw new Error(json.error || res.statusText);
msg.textContent = '已新增,請記得重新編譯。';
e.target.reset();
refresh();
} catch (err) {
msg.className = 'err';
msg.textContent = '上傳失敗:' + err.message;
} finally {
btn.disabled = false;
}
});
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('讀取照片失敗:' + url));
img.src = url;
});
}
let compilerLoaded = false;
function loadCompilerScript() {
if (compilerLoaded) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.type = 'module'; // mind-ar 編譯器是 ES module,會自行掛上 window.MINDAR
s.src = '/vendor/mindar-image.prod.js';
s.onload = () => { compilerLoaded = true; resolve(); };
s.onerror = () => reject(new Error('無法載入 MindAR 編譯器'));
document.head.appendChild(s);
});
}
document.getElementById('compileBtn').addEventListener('click', async () => {
const btn = document.getElementById('compileBtn');
const status = document.getElementById('compileStatus');
const bar = document.getElementById('compileProgress');
if (!state || !state.pairs.length) {
status.textContent = '沒有任何配對可編譯。';
return;
}
btn.disabled = true;
bar.hidden = false;
bar.value = 0;
try {
status.textContent = '載入編譯器…';
await loadCompilerScript();
status.textContent = '載入照片…';
const pairs = state.pairs;
const images = await Promise.all(
pairs.map((p) => loadImage('/data/photos/' + encodeURIComponent(p.photo)))
);
status.textContent = '編譯特徵中(勿關閉頁面)…';
const compiler = new window.MINDAR.IMAGE.Compiler();
await compiler.compileImageTargets(images, (progress) => {
bar.value = progress;
status.textContent = '編譯特徵中 ' + progress.toFixed(0) + '%(勿關閉頁面)…';
});
const buffer = await compiler.exportData();
status.textContent = '上傳特徵檔…';
const mapping = {
targets: pairs.map((p, i) => ({
pairId: p.id,
aspect: images[i].naturalHeight / images[i].naturalWidth,
})),
};
const fd = new FormData();
fd.append('mapping', JSON.stringify(mapping));
fd.append('mind', new Blob([buffer]), 'targets.mind');
const res = await fetch('/api/targets', { method: 'POST', body: fd });
const json = await res.json();
if (!res.ok) throw new Error(json.error || res.statusText);
status.textContent = '編譯完成!';
refresh();
} catch (err) {
status.textContent = '編譯失敗:' + err.message;
} finally {
btn.disabled = false;
bar.hidden = true;
}
});
refresh();
</script>
</body>
</html>
+217
View File
@@ -0,0 +1,217 @@
<!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>