初始化 360 全景影片播放器專案並完成 NAS 部署設定

摘要:
將本機開發的 360° 全景影片播放器納入版控,同時把執行環境從 Windows
切換到 Synology NAS,改以 Docker 部署。

根本原因:
專案原本只在有 NVIDIA 顯卡的 Windows 機器上跑,config.json 寫死了
Windows 磁碟機路徑 W:/photo/Badminton,NAS 上無法直接啟動。
另外 DSM 內建的 ffmpeg 拿掉了 VAAPI 編碼器,裸跑只能走 libx264,
Celeron J4025 雙核轉 4K 360 影片的速度無法接受。

影響:
- 在 NAS 上 npm start 會因為找不到影片資料夾而列不出任何影片
- 即使把路徑改對,轉檔仍只能用 CPU,82 分鐘的 4K 360 影片要跑十幾小時

修法:
- config.json 的 videoDir 改為 NAS 實際路徑 /volume1/photo/Badminton
- docker-compose.yml 啟用 devices: /dev/dri,讓容器取得 Intel UHD 600
  的 render node;容器內 Alpine 版 ffmpeg 保有完整 VAAPI 支援,啟動時
  會自動偵測成 vaapi 模式,並保留 libx264 當備援
- .env(不進版控)提供 VIDEO_DIR / CACHE_DIR 等 NAS 路徑給 compose 使用

驗證:
容器啟動 log 顯示「轉檔引擎:VAAPI 硬體編碼(Intel/AMD,/dev/dri)」,
/api/config 回傳 modes: ["vaapi","cpu"],/api/videos 正確辨識來源影片為
3840x1920 equirectangular。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 10:57:17 +08:00
co-authored by Claude Opus 5
commit 2a9e59357e
17 changed files with 2966 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
import express from 'express';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { ProbeCache } from './lib/probe.js';
import { Transcoder, MODE_LABEL } from './lib/transcode.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// CONFIG=path/to/other.json lets you run a second instance (e.g. for tests) without touching config.json.
const configPath = path.resolve(__dirname, process.env.CONFIG || 'config.json');
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
// Environment overrides (used by the Docker image: /videos, /cache).
if (process.env.PORT) config.port = Number(process.env.PORT);
if (process.env.HOST) config.host = process.env.HOST;
if (process.env.VIDEO_DIR) config.videoDir = process.env.VIDEO_DIR;
if (process.env.CACHE_DIR) config.cacheDir = process.env.CACHE_DIR;
const VIDEO_DIR = path.resolve(__dirname, config.videoDir);
const CACHE_DIR = path.resolve(__dirname, config.cacheDir);
const EXT = new Set((config.extensions || ['.mp4']).map(e => e.toLowerCase()));
const QUALITY_IDS = new Set(config.qualities.map(q => q.id));
const probeCache = new ProbeCache(path.join(CACHE_DIR, 'probe-cache.json'));
const isSafeName = (n) =>
typeof n === 'string' && n.length > 0 && n !== '.' && n !== '..' &&
!n.includes('/') && !n.includes('\\') && path.basename(n) === n;
async function resolveSource(name) {
if (!isSafeName(name)) throw Object.assign(new Error('檔名不合法'), { status: 400 });
const file = path.join(VIDEO_DIR, name);
let stat;
try { stat = await fs.stat(file); } catch { throw Object.assign(new Error('找不到影片'), { status: 404 }); }
const info = await probeCache.get(file, stat);
return { file, stat, info };
}
const transcoder = new Transcoder({ cacheDir: CACHE_DIR, qualities: config.qualities, resolveSource });
/** Run async fn over items with bounded concurrency, preserving order. */
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
}));
return out;
}
async function listVideos() {
let entries;
try { entries = await fs.readdir(VIDEO_DIR, { withFileTypes: true }); }
catch (e) { throw Object.assign(new Error(`無法讀取資料夾 ${VIDEO_DIR}${e.message}`), { status: 500 }); }
const names = entries
.filter(e => e.isFile() && EXT.has(path.extname(e.name).toLowerCase()))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'zh-Hant', { numeric: true }));
return mapLimit(names, 4, describeVideo);
}
async function describeVideo(name) {
const file = path.join(VIDEO_DIR, name);
const stat = await fs.stat(file);
let info = null, error = null;
try { info = await probeCache.get(file, stat); }
catch (e) { error = e.message; }
const variants = {};
if (info?.video) {
for (const q of config.qualities) {
if (q.width >= info.video.width) continue; // would not shrink anything
const height = Math.round((q.width * info.video.height) / info.video.width / 2) * 2;
variants[q.id] = {
label: q.label, width: q.width, height, bitrate: q.bitrate,
...(await transcoder.variantStatus(name, q.id, stat)),
};
}
}
return { name, size: stat.size, mtime: stat.mtimeMs, info, error, variants };
}
const app = express();
app.disable('x-powered-by');
app.use(express.json());
// Access log for media requests (set LOG_STREAM=0 to silence).
if (process.env.LOG_STREAM !== '0') {
app.use('/stream', (req, res, next) => {
const t0 = Date.now();
res.on('close', () => {
const sent = res.socket ? res.socket.bytesWritten : 0;
console.log(`[stream] ${res.statusCode} ${decodeURIComponent(req.url)} range=${req.headers.range || '-'} ` +
`${(res.getHeader('content-length') || '?')}B ${Date.now() - t0}ms${res.writableFinished ? '' : ' (aborted)'}`);
});
next();
});
}
app.get('/api/config', (req, res) => {
res.json({
videoDir: VIDEO_DIR,
cacheDir: CACHE_DIR,
qualities: config.qualities,
encoder: { modes: transcoder.modes, label: MODE_LABEL[transcoder.modes[0]] },
});
});
app.get('/api/videos', async (req, res) => {
res.json(await listVideos());
});
app.get('/api/jobs', (req, res) => res.json(transcoder.list()));
/**
* Body: { name, quality: "1920" } | { name, qualities: ["2560","1920"] } | { name, all: true }
* `all` = every applicable quality that is not ready yet, produced in one pass.
*/
app.post('/api/transcode', async (req, res) => {
const { name, quality, qualities, all } = req.body || {};
if (!isSafeName(name)) return res.status(400).json({ error: '檔名不合法' });
let qids;
if (all) {
const v = await describeVideo(name);
qids = Object.entries(v.variants).filter(([, s]) => !s.ready).map(([id]) => id);
if (!qids.length) return res.status(409).json({ error: '所有畫質都已經轉檔完成' });
} else {
qids = Array.isArray(qualities) ? qualities.map(String) : [String(quality)];
if (!qids.length || qids.some(q => !QUALITY_IDS.has(q))) return res.status(400).json({ error: '未知的畫質' });
}
res.json(await transcoder.enqueue(name, qids));
});
app.delete('/api/jobs/finished', (req, res) => { transcoder.clearFinished(); res.json({ ok: true }); });
app.delete('/api/jobs/:id', (req, res) => res.json(transcoder.cancel(req.params.id)));
app.delete('/api/variant', async (req, res) => {
const { name, quality } = req.query;
if (!isSafeName(name)) return res.status(400).json({ error: '檔名不合法' });
if (!QUALITY_IDS.has(String(quality))) return res.status(400).json({ error: '未知的畫質' });
await transcoder.deleteVariant(name, String(quality));
res.json({ ok: true });
});
// ---- Server-sent events: job progress pushed to every open page ----
const sseClients = new Set();
let sseTimer = null;
function broadcast(type, data) {
const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
for (const c of sseClients) c.write(payload);
}
transcoder.on('update', () => {
if (sseTimer) return; // throttle progress spam to ~4/s
sseTimer = setTimeout(() => { sseTimer = null; broadcast('jobs', transcoder.list()); }, 250);
});
transcoder.on('finished', job => broadcast('finished', job));
app.get('/api/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 2000\n\n');
res.write(`event: jobs\ndata: ${JSON.stringify(transcoder.list())}\n\n`);
sseClients.add(res);
const ping = setInterval(() => res.write(': ping\n\n'), 20000);
req.on('close', () => { clearInterval(ping); sseClients.delete(res); });
});
// ---- Media streaming with HTTP Range support ----
const MIME = { '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/mp4', '.webm': 'video/webm' };
app.get('/stream/:name', async (req, res) => {
const name = req.params.name;
if (!isSafeName(name)) return res.status(400).end();
const q = String(req.query.q || 'original');
let file;
if (q === 'original') file = path.join(VIDEO_DIR, name);
else if (QUALITY_IDS.has(q)) file = transcoder.variantPath(name, q);
else return res.status(400).end();
try { await fs.access(file); } catch { return res.status(404).end(); }
res.sendFile(file, {
acceptRanges: true,
cacheControl: false,
etag: false,
lastModified: true,
dotfiles: 'allow',
headers: {
'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
'Cache-Control': 'no-cache',
},
}, err => {
// Client aborts (seeking, closing the tab) surface here; nothing to do.
if (err && !res.headersSent && err.code !== 'ECONNABORTED') res.status(err.status || 500).end();
});
});
app.use('/vendor/three', express.static(path.join(__dirname, 'node_modules/three/build'), { maxAge: '1d' }));
app.use(express.static(path.join(__dirname, 'public')));
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'server error' });
});
// ---- Start ----
await probeCache.load();
const modes = await transcoder.init();
const server = app.listen(config.port, config.host, () => {
console.log(`360 Player`);
console.log(` 影片資料夾 : ${VIDEO_DIR}`);
console.log(` 轉檔快取 : ${CACHE_DIR}`);
console.log(` 轉檔引擎 : ${MODE_LABEL[modes[0]]} (備援: ${modes.slice(1).map(m => MODE_LABEL[m]).join(' → ') || '無'})`);
console.log(` 網址 : http://localhost:${config.port}`);
for (const [ifname, addrs] of Object.entries(os.networkInterfaces())) {
for (const a of addrs) {
if (a.family === 'IPv4' && !a.internal) console.log(` http://${a.address}:${config.port} (${ifname})`);
}
}
});
server.keepAliveTimeout = 65000;
// Graceful stop (docker stop / Ctrl+C): kill the running ffmpeg so no half-written .part survives.
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => {
console.log(`收到 ${sig},關閉中…`);
if (transcoder.running) transcoder.cancel(transcoder.running.id);
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 3000).unref();
});
}