Files
photo-live/server.js
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

255 lines
8.8 KiB
JavaScript
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.
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' },
});
}
// 管理 app3510HTTP):root 是管理頁
const adminApp = express();
adminApp.get('/', sendPage('admin.html'));
adminApp.get('/admin', (req, res) => res.redirect('/'));
adminApp.use(shared);
adminApp.use(errorHandler);
// 掃描 app3511HTTPS):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} (手機請走這個)`);
});