Files
360_player/server.js
T
JianMiauandClaude Opus 5 2a9e59357e 初始化 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>
2026-08-21 10:57:17 +08:00

235 lines
9.4 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.
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();
});
}