Files
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

388 lines
14 KiB
JavaScript
Raw Permalink 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 { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import { ffprobe } from './probe.js';
import { injectSphericalV1 } from './spherical.js';
export const MODE_LABEL = {
cuda: 'NVDEC + CUDA 縮放 + NVENC',
nvenc: 'CPU 解碼/縮放 + NVENC',
vaapi: 'VAAPI 硬體編碼(Intel/AMD/dev/dri',
cpu: 'libx264CPU',
};
const VAAPI_DEVICE = process.env.VAAPI_DEVICE || '/dev/dri/renderD128';
const X264_PRESET = process.env.X264_PRESET || 'veryfast';
function tryFfmpeg(args, timeoutMs = 20000) {
return new Promise(resolve => {
const p = spawn('ffmpeg', ['-hide_banner', '-v', 'error', '-y', ...args], { windowsHide: true });
const t = setTimeout(() => { p.kill(); resolve(false); }, timeoutMs);
p.on('error', () => { clearTimeout(t); resolve(false); });
p.on('close', code => { clearTimeout(t); resolve(code === 0); });
});
}
/** Figure out which encode pipelines work on this machine, best first. */
export async function detectModes() {
const modes = [];
const src = ['-f', 'lavfi', '-i', 'testsrc=s=256x128:d=0.1:r=30'];
if (await tryFfmpeg([...src, '-vf', 'hwupload_cuda,scale_cuda=128:64', '-c:v', 'h264_nvenc', '-f', 'null', '-'])) {
modes.push('cuda', 'nvenc');
} else if (await tryFfmpeg([...src, '-c:v', 'h264_nvenc', '-f', 'null', '-'])) {
modes.push('nvenc');
}
// Intel/AMD iGPU via VAAPI (typical for a NAS with /dev/dri passed into the container).
if (await fs.access(VAAPI_DEVICE).then(() => true, () => false) &&
await tryFfmpeg(['-vaapi_device', VAAPI_DEVICE, ...src,
'-vf', 'format=nv12,hwupload,scale_vaapi=128:64', '-c:v', 'h264_vaapi', '-f', 'null', '-'])) {
modes.push('vaapi');
}
modes.push('cpu');
return modes;
}
/** "7M" * 2 -> "14M" (keeps the unit suffix). */
function scaleRate(rate, factor) {
const m = /^([\d.]+)\s*([kKmM]?)$/.exec(String(rate));
if (!m) return rate;
return `${Math.round(parseFloat(m[1]) * factor)}${m[2]}`;
}
/**
* One ffmpeg run, one decode of the source, N scaled outputs. Reading the
* source is usually the slow part (network drive), so every extra quality is
* nearly free when produced in the same pass.
*/
function buildArgs({ mode, src, targets, audioCodec }) {
const a = ['-y', '-hide_banner', '-loglevel', 'error', '-nostats'];
if (mode === 'cuda') a.push('-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda');
if (mode === 'vaapi') a.push('-vaapi_device', VAAPI_DEVICE, '-hwaccel', 'vaapi', '-hwaccel_output_format', 'vaapi');
a.push('-i', src);
for (const t of targets) {
a.push('-map', '0:v:0', '-map', '0:a?', '-sn', '-dn');
if (mode === 'cuda') a.push('-vf', `scale_cuda=${t.width}:${t.height}`);
// format=nv12|vaapi,hwupload: works whether the decoder ran on the GPU or fell back to software.
else if (mode === 'vaapi') a.push('-vf', `format=nv12|vaapi,hwupload,scale_vaapi=${t.width}:${t.height}:format=nv12`);
else if (mode === 'nvenc') a.push('-vf', `scale=${t.width}:${t.height}:flags=bicubic,format=nv12`);
else a.push('-vf', `scale=${t.width}:${t.height}:flags=bicubic,format=yuv420p`);
const bufsize = scaleRate(t.maxrate, 2);
if (mode === 'cpu') {
a.push('-c:v', 'libx264', '-preset', X264_PRESET, '-crf', '23',
'-maxrate', t.maxrate, '-bufsize', bufsize, '-profile:v', 'high', '-g', '60');
} else if (mode === 'vaapi') {
a.push('-c:v', 'h264_vaapi', '-rc_mode', 'VBR', '-b:v', t.bitrate, '-maxrate', t.maxrate, '-bufsize', bufsize,
'-profile:v', 'high', '-g', '60', '-bf', '2');
} else {
a.push('-c:v', 'h264_nvenc', '-preset', t.nvencPreset || 'p4', '-rc', 'vbr',
'-b:v', t.bitrate, '-maxrate', t.maxrate, '-bufsize', bufsize,
'-profile:v', 'high', '-g', '60', '-bf', '2', '-spatial-aq', '1');
}
if (audioCodec === 'aac') a.push('-c:a', 'copy');
else a.push('-c:a', 'aac', '-b:a', '160k');
a.push('-movflags', '+faststart', t.part);
}
a.push('-progress', 'pipe:1');
return a;
}
export class Transcoder extends EventEmitter {
/**
* @param {object} o
* @param {string} o.cacheDir
* @param {Array} o.qualities config.qualities
* @param {(name:string)=>Promise<{file:string,stat:import('fs').Stats,info:object}>} o.resolveSource
*/
constructor({ cacheDir, qualities, resolveSource }) {
super();
this.cacheDir = cacheDir;
this.qualities = new Map(qualities.map(q => [q.id, q]));
this.resolveSource = resolveSource;
this.modes = ['cpu'];
this.jobs = new Map(); // id -> job
this.queue = []; // job ids waiting
this.running = null; // job currently encoding
}
async init() {
await fs.mkdir(this.cacheDir, { recursive: true });
this.modes = await detectModes();
// Remove leftovers from a previous crash.
for (const f of await fs.readdir(this.cacheDir)) {
if (f.endsWith('.part.mp4') || f.endsWith('.sph.tmp')) await fs.rm(path.join(this.cacheDir, f), { force: true });
}
return this.modes;
}
variantPath(name, qid) {
return path.join(this.cacheDir, `${name}__${qid}.mp4`);
}
/** Is a finished, up-to-date variant on disk? Stale ones (source changed) are deleted. */
async variantStatus(name, qid, sourceStat) {
const out = this.variantPath(name, qid);
try {
const meta = JSON.parse(await fs.readFile(out + '.json', 'utf8'));
const st = await fs.stat(out);
// mtime tolerance of 2 s: SMB/WebDAV/FAT round timestamps, and the cache may be copied
// between the PC (GPU transcode) and the NAS (serving) that see the same source file.
if (meta.sourceSize === sourceStat.size && Math.abs(meta.sourceMtime - sourceStat.mtimeMs) < 2000) {
return { ready: true, size: st.size, mode: meta.mode, createdAt: meta.createdAt, spherical: !!meta.spherical };
}
await this.deleteVariant(name, qid);
} catch { /* not there */ }
return { ready: false };
}
async deleteVariant(name, qid) {
const job = this.findJob(name, qid);
if (job) this.cancel(job.id);
const out = this.variantPath(name, qid);
await fs.rm(out, { force: true });
await fs.rm(out + '.json', { force: true });
}
/** Active (queued/running) job that will produce this variant, if any. */
findJob(name, qid) {
for (const j of this.jobs.values()) {
if (j.name === name && (j.status === 'queued' || j.status === 'running') && j.targets.some(t => t.quality === qid)) return j;
}
return null;
}
list() {
return [...this.jobs.values()].map(publicJob);
}
/**
* Queue one job that produces every quality in `qids` from a single read of
* the source. Qualities that already exist or are already in flight are skipped.
*/
async enqueue(name, qids) {
const { file, stat, info } = await this.resolveSource(name);
if (!info?.video) throw httpError(400, '無法讀取影片資訊');
const targets = [];
const skipped = [];
for (const qid of qids) {
const q = this.qualities.get(qid);
if (!q) throw httpError(400, `未知的畫質 ${qid}`);
if (q.width >= info.video.width) { skipped.push(`${q.label}:不小於原始解析度`); continue; }
if (this.findJob(name, qid)) { skipped.push(`${q.label}:已在佇列中`); continue; }
if ((await this.variantStatus(name, qid, stat)).ready) { skipped.push(`${q.label}:已存在`); continue; }
const height = Math.round((q.width * info.video.height) / info.video.width / 2) * 2;
targets.push({
quality: qid, label: q.label, width: q.width, height,
bitrate: q.bitrate, maxrate: q.maxrate, nvencPreset: q.nvencPreset,
final: this.variantPath(name, qid),
part: this.variantPath(name, qid).replace(/\.mp4$/, '.part.mp4'),
done: false,
});
}
if (!targets.length) throw httpError(409, skipped.length ? `沒有需要轉檔的畫質(${skipped.join('')}` : '沒有需要轉檔的畫質');
targets.sort((a, b) => b.width - a.width);
const job = {
id: crypto.randomBytes(6).toString('hex'),
name, targets, skipped,
src: file, sourceStat: { size: stat.size, mtime: Math.floor(stat.mtimeMs) },
duration: info.duration || 0,
audioCodec: info.audio?.codec || null,
spherical: info.projection === 'equirectangular',
status: 'queued', progress: 0, fps: 0, speed: 0, eta: null, outTime: 0,
mode: null, attempt: 0, note: null, error: null,
createdAt: Date.now(), startedAt: null, finishedAt: null,
_proc: null,
};
this.jobs.set(job.id, job);
this.queue.push(job.id);
this.emit('update');
this.pump();
return publicJob(job);
}
cancel(id) {
const job = this.jobs.get(id);
if (!job) throw httpError(404, '找不到工作');
if (job.status === 'queued') {
this.queue = this.queue.filter(x => x !== id);
job.status = 'cancelled';
job.finishedAt = Date.now();
this.emit('update');
} else if (job.status === 'running') {
job.status = 'cancelled';
job._proc?.kill();
}
return publicJob(job);
}
/** Drop finished entries from the list. */
clearFinished() {
for (const [id, j] of this.jobs) {
if (j.status === 'done' || j.status === 'error' || j.status === 'cancelled') this.jobs.delete(id);
}
this.emit('update');
}
async pump() {
if (this.running || !this.queue.length) return;
const job = this.jobs.get(this.queue.shift());
if (!job || job.status !== 'queued') return this.pump();
this.running = job;
job.status = 'running';
job.startedAt = Date.now();
this.emit('update');
try {
await this.run(job);
} catch (e) {
job.status = 'error';
job.error = e.message;
}
job.finishedAt = Date.now();
this.running = null;
this.emit('update');
this.emit('finished', publicJob(job));
this.pump();
}
async removeParts(job) {
for (const t of job.targets) await fs.rm(t.part, { force: true });
}
run(job) {
return new Promise(resolve => {
const attempt = (i) => {
const mode = this.modes[i];
job.mode = mode;
job.attempt = i + 1;
job.progress = 0; job.outTime = 0; job.fps = 0; job.speed = 0; job.eta = null;
const args = buildArgs({ mode, src: job.src, targets: job.targets, audioCodec: job.audioCodec });
const p = spawn('ffmpeg', args, { windowsHide: true });
job._proc = p;
let buf = '';
const stderr = [];
p.stdout.on('data', d => {
buf += d;
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
this.progressLine(job, line);
}
});
p.stderr.on('data', d => {
stderr.push(String(d));
if (stderr.length > 40) stderr.shift();
});
p.on('error', e => stderr.push(e.message));
p.on('close', async code => {
job._proc = null;
if (job.status === 'cancelled') {
await this.removeParts(job);
return resolve();
}
if (code === 0) {
try {
for (const t of job.targets) await this.finalize(job, t, mode);
job.status = 'done';
job.progress = 1;
job.eta = 0;
job.note = null;
} catch (e) {
job.status = 'error';
job.error = `寫入輸出失敗:${e.message}`;
await this.removeParts(job);
}
return resolve();
}
const errText = stderr.join('').trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' | ');
await this.removeParts(job);
if (i + 1 < this.modes.length) {
job.note = `${MODE_LABEL[mode]} 失敗(${errText || 'exit ' + code}),改用 ${MODE_LABEL[this.modes[i + 1]]}`;
console.warn(`[transcode] ${job.name}: ${job.note}`);
this.emit('update');
return attempt(i + 1);
}
job.status = 'error';
job.error = errText || `ffmpeg exited ${code}`;
resolve();
});
};
attempt(0);
});
}
/** Re-attach 360 metadata (ffmpeg drops it on re-encode), verify, then publish the variant. */
async finalize(job, t, mode) {
let spherical = false;
if (job.spherical) {
job.note = `寫入 360 metadata${t.label})…`;
this.emit('update');
const tmp = t.final.replace(/\.mp4$/, '.sph.tmp');
try {
const r = await injectSphericalV1(t.part, tmp);
if (r.injected) {
const check = await ffprobe(tmp);
if (check.projectionSource === 'metadata' && check.duration > 0) {
await fs.rm(t.part, { force: true });
await fs.rename(tmp, t.part);
spherical = true;
} else {
console.warn(`[transcode] spherical 注入後驗證失敗,保留未注入版本 (${job.name} ${t.quality})`);
await fs.rm(tmp, { force: true });
}
} else {
console.warn(`[transcode] 未注入 spherical metadata${r.reason}`);
}
} catch (e) {
console.warn(`[transcode] spherical 注入失敗:${e.message}`);
await fs.rm(tmp, { force: true });
}
}
await fs.writeFile(t.final + '.json', JSON.stringify({
sourceSize: job.sourceStat.size, sourceMtime: job.sourceStat.mtime,
width: t.width, height: t.height, mode, spherical, createdAt: Date.now(),
}));
await fs.rename(t.part, t.final);
t.done = true;
}
progressLine(job, line) {
const eq = line.indexOf('=');
if (eq < 0) return;
const key = line.slice(0, eq);
const val = line.slice(eq + 1).trim();
switch (key) {
case 'out_time_us': job.outTime = Number(val) / 1e6; break;
case 'out_time_ms': if (!job.outTime) job.outTime = Number(val) / 1e6; break; // older ffmpeg: actually µs
case 'fps': job.fps = parseFloat(val) || 0; break;
case 'speed': job.speed = parseFloat(val) || 0; break;
case 'progress': {
if (job.duration > 0) {
job.progress = Math.min(0.999, job.outTime / job.duration);
job.eta = job.speed > 0 ? Math.max(0, (job.duration - job.outTime) / job.speed) : null;
}
this.emit('update');
break;
}
}
}
}
function publicJob(j) {
const { _proc, src, sourceStat, ...rest } = j;
rest.targets = j.targets.map(({ quality, label, width, height, done }) => ({ quality, label, width, height, done }));
return rest;
}
function httpError(status, message) {
const e = new Error(message);
e.status = status;
return e;
}