新增動態相片 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,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