新增動態相片 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:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
data
|
||||
public/vendor
|
||||
*.md
|
||||
.git
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
public/vendor/
|
||||
*.stackdump
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# tzdata 讓容器能透過 TZ 環境變數使用本地時區(配對名稱以當下時間命名)
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# 建置時抓取前端函式庫,之後在區網離線環境也能使用
|
||||
# mind-ar 1.2.2 的編譯器是 ES module,需要一併抓取它的 chunk 檔(檔名含 hash,隨版本固定)
|
||||
ARG AFRAME_VERSION=1.4.2
|
||||
ARG MINDAR_VERSION=1.2.2
|
||||
RUN mkdir -p public/vendor \
|
||||
&& wget -q -O public/vendor/aframe.min.js "https://aframe.io/releases/${AFRAME_VERSION}/aframe.min.js" \
|
||||
&& wget -q -O public/vendor/mindar-image-aframe.prod.js "https://cdn.jsdelivr.net/npm/mind-ar@${MINDAR_VERSION}/dist/mindar-image-aframe.prod.js" \
|
||||
&& wget -q -O public/vendor/mindar-image.prod.js "https://cdn.jsdelivr.net/npm/mind-ar@${MINDAR_VERSION}/dist/mindar-image.prod.js" \
|
||||
&& wget -q -O public/vendor/controller-495b585f.js "https://cdn.jsdelivr.net/npm/mind-ar@${MINDAR_VERSION}/dist/controller-495b585f.js" \
|
||||
&& wget -q -O public/vendor/ui-85e81035.js "https://cdn.jsdelivr.net/npm/mind-ar@${MINDAR_VERSION}/dist/ui-85e81035.js"
|
||||
|
||||
COPY server.js ./
|
||||
COPY public ./public
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3510 3511
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,67 @@
|
||||
# photo-live 動態相片
|
||||
|
||||
把「照片」當成 key、「影片」當成 value 的 Web AR 服務。
|
||||
用手機相機掃描照片,辨識成功後影片會貼齊照片的位置與角度覆蓋播放
|
||||
(效果同小米相片印表機的動態照片功能)。
|
||||
|
||||
核心採用 [MindAR](https://github.com/hiukim/mind-ar-js)(瀏覽器端影像追蹤),
|
||||
手機不用安裝 App,開網頁就能用。
|
||||
|
||||
## 部署(NAS Docker)
|
||||
|
||||
```bash
|
||||
sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
啟動後:
|
||||
|
||||
| 頁面 | 網址 | 用途 |
|
||||
|---|---|---|
|
||||
| 動態相片管理 | `http://NAS的IP:3510/` | 上傳照片+影片配對、編譯辨識特徵 |
|
||||
| 動態相片掃描 | `https://NAS的IP:3511/` | 手機開啟,掃描照片播放影片 |
|
||||
|
||||
Volume 對應(`docker-compose.yml`):
|
||||
|
||||
| NAS 路徑 | 容器路徑 | 內容 |
|
||||
|---|---|---|
|
||||
| `/volume1/docker/photo-live` | `/data` | 照片、影片、特徵檔(重建容器不會遺失) |
|
||||
| `/volume1/docker/certs` | `/certs`(唯讀) | HTTPS 正式憑證 |
|
||||
|
||||
### 憑證
|
||||
|
||||
伺服器啟動時依序尋找憑證:
|
||||
|
||||
1. 環境變數 `SSL_KEY` / `SSL_CERT` 指定的完整路徑
|
||||
2. `/certs` 目錄下的常見檔名組合:`privkey.pem`+`fullchain.pem`、
|
||||
`server.key`+`server.crt`、`key.pem`+`cert.pem` 等
|
||||
3. 都找不到就自動產生自簽憑證(瀏覽器會出現警告)
|
||||
|
||||
把 Synology 憑證(控制台 → 安全性 → 憑證 → 匯出)的 `privkey.pem` 與
|
||||
`fullchain.pem` 放進 `/volume1/docker/certs` 即可。注意:手機要用**憑證對應的網域**
|
||||
連掃描頁(`https://你的網域:3511/`),用 IP 連的話憑證不會生效、仍會跳警告。
|
||||
更換憑證後重啟容器:`sudo docker compose restart`。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 用**桌機瀏覽器**開管理頁,上傳一張照片與對應影片(可多組)。
|
||||
2. 按「**重新編譯全部照片**」。特徵編譯在瀏覽器端執行,每張照片約 10~30 秒,
|
||||
完成後自動上傳到伺服器。新增或刪除配對後都要重新編譯。
|
||||
3. 手機開啟 `https://NAS的IP:3511/`,因為是自簽憑證,瀏覽器會警告
|
||||
「連線不安全」→ 選「進階」→「仍要前往」。
|
||||
4. 按「開始掃描」並允許相機權限,對準實體照片(或螢幕上的照片)即可。
|
||||
|
||||
## 注意事項
|
||||
|
||||
- **一定要用 HTTPS 開掃描頁**:瀏覽器規定相機只能在安全連線下使用。
|
||||
若 NAS 已有反向代理(如 Synology 的反向代理伺服器+正式憑證),
|
||||
可以把網域用 HTTPS 指到容器的 3511 埠,體驗更好(沒有憑證警告)。
|
||||
- **照片建議**:紋理豐富、對比清楚的照片辨識效果最好;
|
||||
大面積純色、過暗或重複花紋的照片不易辨識。
|
||||
- **影片建議**:H.264 編碼的 MP4 相容性最好(iOS/Android 皆可播)。
|
||||
影片長寬比最好與照片一致,播放時才不會變形。
|
||||
- iOS 若無法自動播放聲音,影片會退回靜音播放(瀏覽器限制)。
|
||||
|
||||
## 埠號調整
|
||||
|
||||
改 `docker-compose.yml` 的 ports 即可,例如 `"9510:3510"`、`"9511:3511"`。
|
||||
(頁面上顯示的互連連結取自容器內埠號,若對外映射成不同埠號,請自行以對外埠號連線。)
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
photo-live:
|
||||
build: .
|
||||
container_name: photo-live
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Taipei
|
||||
- DATA_DIR=/data
|
||||
- CERT_DIR=/certs
|
||||
ports:
|
||||
- "3510:3510" # 動態相片管理(HTTP)
|
||||
- "3511:3511" # 動態相片掃描(HTTPS,相機權限需要安全連線)
|
||||
volumes:
|
||||
- /volume1/docker/photo-live:/data # 照片/影片/特徵檔等資料
|
||||
- /volume1/docker/certs:/certs:ro # 正式憑證(唯讀)
|
||||
Generated
+1046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "photo-live",
|
||||
"version": "1.0.0",
|
||||
"description": "掃描照片播放對應影片的 Web AR 服務(照片=key、影片=value)",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"selfsigned": "^2.4.1"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
/* 背景只能放在 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;
|
||||
}
|
||||
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>
|
||||
@@ -0,0 +1,254 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const multer = require('multer');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const PHOTO_DIR = path.join(DATA_DIR, 'photos');
|
||||
const VIDEO_DIR = path.join(DATA_DIR, 'videos');
|
||||
const SELF_CERT_DIR = path.join(DATA_DIR, 'certs'); // 自簽憑證存放處(找不到外部憑證時的備援)
|
||||
const EXTERNAL_CERT_DIR = process.env.CERT_DIR || ''; // 外掛正式憑證目錄(例如掛載 /certs)
|
||||
const TMP_DIR = path.join(DATA_DIR, 'tmp');
|
||||
for (const d of [DATA_DIR, PHOTO_DIR, VIDEO_DIR, SELF_CERT_DIR, TMP_DIR]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
}
|
||||
|
||||
const DB_FILE = path.join(DATA_DIR, 'pairs.json');
|
||||
const TARGETS_META_FILE = path.join(DATA_DIR, 'targets.json');
|
||||
const TARGETS_MIND_FILE = path.join(DATA_DIR, 'targets.mind');
|
||||
|
||||
const ADMIN_PORT = Number(process.env.ADMIN_PORT) || 3510; // 管理頁(HTTP)
|
||||
const VIEWER_PORT = Number(process.env.VIEWER_PORT) || 3511; // 掃描頁(HTTPS,相機需要安全連線)
|
||||
|
||||
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png']);
|
||||
const VIDEO_EXTS = new Set(['.mp4', '.m4v', '.mov', '.webm']);
|
||||
|
||||
function timestampName(d = new Date()) {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return (
|
||||
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
||||
`_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
function loadJson(file, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function saveJson(file, obj) {
|
||||
fs.writeFileSync(file, JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
let db = loadJson(DB_FILE, { pairs: [], updatedAt: 0 });
|
||||
|
||||
function persistDb() {
|
||||
db.updatedAt = Date.now();
|
||||
saveJson(DB_FILE, db);
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
dest: TMP_DIR,
|
||||
limits: { fileSize: 2 * 1024 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
// 共用路由(API 與資料檔),掛在管理與掃描兩個 app 上
|
||||
const shared = express.Router();
|
||||
shared.use('/vendor', express.static(path.join(__dirname, 'public', 'vendor')));
|
||||
shared.use('/data/photos', express.static(PHOTO_DIR));
|
||||
shared.use('/data/videos', express.static(VIDEO_DIR));
|
||||
|
||||
shared.get('/data/targets.mind', (req, res) => {
|
||||
if (!fs.existsSync(TARGETS_MIND_FILE)) return res.status(404).json({ error: 'no targets' });
|
||||
res.sendFile(TARGETS_MIND_FILE);
|
||||
});
|
||||
|
||||
function getState() {
|
||||
const targets = loadJson(TARGETS_META_FILE, null);
|
||||
const needsCompile =
|
||||
db.pairs.length > 0
|
||||
? !targets || (targets.compiledAt || 0) < db.updatedAt
|
||||
: !!(targets && targets.targets.length > 0);
|
||||
return {
|
||||
pairs: db.pairs,
|
||||
targets,
|
||||
needsCompile,
|
||||
ports: { admin: ADMIN_PORT, viewer: VIEWER_PORT },
|
||||
};
|
||||
}
|
||||
|
||||
shared.get('/api/state', (req, res) => {
|
||||
res.json(getState());
|
||||
});
|
||||
|
||||
function cleanupTmp(req) {
|
||||
const files = [];
|
||||
if (req.file) files.push(req.file);
|
||||
if (req.files) for (const list of Object.values(req.files)) files.push(...list);
|
||||
for (const f of files) fs.rm(f.path, { force: true }, () => {});
|
||||
}
|
||||
|
||||
shared.post(
|
||||
'/api/pairs',
|
||||
upload.fields([
|
||||
{ name: 'photo', maxCount: 1 },
|
||||
{ name: 'video', maxCount: 1 },
|
||||
]),
|
||||
(req, res) => {
|
||||
const photo = req.files && req.files.photo && req.files.photo[0];
|
||||
const video = req.files && req.files.video && req.files.video[0];
|
||||
if (!photo || !video) {
|
||||
cleanupTmp(req);
|
||||
return res.status(400).json({ error: '需要同時上傳照片與影片' });
|
||||
}
|
||||
const photoExt = path.extname(photo.originalname).toLowerCase();
|
||||
const videoExt = path.extname(video.originalname).toLowerCase();
|
||||
if (!PHOTO_EXTS.has(photoExt)) {
|
||||
cleanupTmp(req);
|
||||
return res.status(400).json({ error: `照片格式僅支援 ${[...PHOTO_EXTS].join(' / ')}` });
|
||||
}
|
||||
if (!VIDEO_EXTS.has(videoExt)) {
|
||||
cleanupTmp(req);
|
||||
return res.status(400).json({ error: `影片格式僅支援 ${[...VIDEO_EXTS].join(' / ')}` });
|
||||
}
|
||||
|
||||
const id = Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
|
||||
const photoFile = id + photoExt;
|
||||
const videoFile = id + videoExt;
|
||||
fs.renameSync(photo.path, path.join(PHOTO_DIR, photoFile));
|
||||
fs.renameSync(video.path, path.join(VIDEO_DIR, videoFile));
|
||||
|
||||
const pair = {
|
||||
id,
|
||||
name: timestampName(),
|
||||
photo: photoFile,
|
||||
video: videoFile,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.pairs.push(pair);
|
||||
persistDb();
|
||||
res.json({ ok: true, pair });
|
||||
}
|
||||
);
|
||||
|
||||
shared.delete('/api/pairs/:id', (req, res) => {
|
||||
const idx = db.pairs.findIndex((p) => p.id === req.params.id);
|
||||
if (idx === -1) return res.status(404).json({ error: '找不到這組配對' });
|
||||
const [pair] = db.pairs.splice(idx, 1);
|
||||
fs.rm(path.join(PHOTO_DIR, pair.photo), { force: true }, () => {});
|
||||
fs.rm(path.join(VIDEO_DIR, pair.video), { force: true }, () => {});
|
||||
persistDb();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// 管理頁在瀏覽器端把所有照片編譯成 .mind 特徵檔後上傳到這裡
|
||||
shared.post('/api/targets', upload.single('mind'), (req, res) => {
|
||||
let mapping;
|
||||
try {
|
||||
mapping = JSON.parse(req.body.mapping);
|
||||
if (!Array.isArray(mapping.targets)) throw new Error('bad mapping');
|
||||
} catch {
|
||||
cleanupTmp(req);
|
||||
return res.status(400).json({ error: 'mapping 格式錯誤' });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: '缺少 .mind 檔' });
|
||||
|
||||
const known = new Set(db.pairs.map((p) => p.id));
|
||||
for (const t of mapping.targets) {
|
||||
if (!known.has(t.pairId)) {
|
||||
cleanupTmp(req);
|
||||
return res.status(400).json({ error: `pairId ${t.pairId} 不存在,請重新整理後再編譯` });
|
||||
}
|
||||
}
|
||||
|
||||
fs.renameSync(req.file.path, TARGETS_MIND_FILE);
|
||||
saveJson(TARGETS_META_FILE, { compiledAt: Date.now(), targets: mapping.targets });
|
||||
res.json({ ok: true, state: getState() });
|
||||
});
|
||||
|
||||
function errorHandler(err, req, res, next) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message || 'server error' });
|
||||
}
|
||||
|
||||
// HTML 一律不快取,避免手機拿到舊版頁面
|
||||
function sendPage(file) {
|
||||
return (req, res) =>
|
||||
res.sendFile(path.join(__dirname, 'public', file), {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
|
||||
// 管理 app(3510,HTTP):root 是管理頁
|
||||
const adminApp = express();
|
||||
adminApp.get('/', sendPage('admin.html'));
|
||||
adminApp.get('/admin', (req, res) => res.redirect('/'));
|
||||
adminApp.use(shared);
|
||||
adminApp.use(errorHandler);
|
||||
|
||||
// 掃描 app(3511,HTTPS):root 是掃描頁
|
||||
const viewerApp = express();
|
||||
viewerApp.get('/', sendPage('index.html'));
|
||||
viewerApp.use(shared);
|
||||
viewerApp.use(errorHandler);
|
||||
|
||||
function ensureCerts() {
|
||||
// 1. 明確指定的憑證檔(環境變數 SSL_KEY / SSL_CERT 為完整路徑)
|
||||
if (process.env.SSL_KEY && process.env.SSL_CERT) {
|
||||
console.log('使用指定憑證:', process.env.SSL_CERT);
|
||||
return { key: fs.readFileSync(process.env.SSL_KEY), cert: fs.readFileSync(process.env.SSL_CERT) };
|
||||
}
|
||||
// 2. 外掛憑證目錄(CERT_DIR),依常見檔名組合搜尋
|
||||
if (EXTERNAL_CERT_DIR) {
|
||||
const candidates = [
|
||||
['privkey.pem', 'fullchain.pem'], // Let's Encrypt / Synology 匯出
|
||||
['privkey.pem', 'cert.pem'],
|
||||
['server.key', 'server.crt'],
|
||||
['key.pem', 'cert.pem'],
|
||||
['ssl.key', 'ssl.crt'],
|
||||
];
|
||||
for (const [k, c] of candidates) {
|
||||
const keyFile = path.join(EXTERNAL_CERT_DIR, k);
|
||||
const certFile = path.join(EXTERNAL_CERT_DIR, c);
|
||||
if (fs.existsSync(keyFile) && fs.existsSync(certFile)) {
|
||||
console.log(`使用外掛憑證: ${certFile} + ${keyFile}`);
|
||||
return { key: fs.readFileSync(keyFile), cert: fs.readFileSync(certFile) };
|
||||
}
|
||||
}
|
||||
console.warn(`在 ${EXTERNAL_CERT_DIR} 找不到憑證(試過: ${candidates.map((p) => p.join('+')).join(', ')}),改用自簽憑證`);
|
||||
}
|
||||
// 3. 備援:自動產生自簽憑證
|
||||
const keyFile = path.join(SELF_CERT_DIR, 'server.key');
|
||||
const certFile = path.join(SELF_CERT_DIR, 'server.crt');
|
||||
if (fs.existsSync(keyFile) && fs.existsSync(certFile)) {
|
||||
return { key: fs.readFileSync(keyFile), cert: fs.readFileSync(certFile) };
|
||||
}
|
||||
const selfsigned = require('selfsigned');
|
||||
const pems = selfsigned.generate([{ name: 'commonName', value: 'photo-live' }], {
|
||||
days: 3650,
|
||||
keySize: 2048,
|
||||
extensions: [
|
||||
{
|
||||
name: 'subjectAltName',
|
||||
altNames: [{ type: 2, value: 'localhost' }, { type: 2, value: 'photo-live' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
fs.writeFileSync(keyFile, pems.private);
|
||||
fs.writeFileSync(certFile, pems.cert);
|
||||
console.log('已產生自簽 HTTPS 憑證於', SELF_CERT_DIR);
|
||||
return { key: pems.private, cert: pems.cert };
|
||||
}
|
||||
|
||||
http.createServer(adminApp).listen(ADMIN_PORT, () => {
|
||||
console.log(`管理頁 (HTTP): http://0.0.0.0:${ADMIN_PORT}`);
|
||||
});
|
||||
https.createServer(ensureCerts(), viewerApp).listen(VIEWER_PORT, () => {
|
||||
console.log(`掃描頁 (HTTPS): https://0.0.0.0:${VIEWER_PORT} (手機請走這個)`);
|
||||
});
|
||||
Reference in New Issue
Block a user