初始化 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
+106
View File
@@ -0,0 +1,106 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
/** Run ffprobe and return a compact description of the file. */
export function ffprobe(file) {
return new Promise((resolve, reject) => {
const args = [
'-v', 'error',
'-show_entries',
'stream=index,codec_type,codec_name,width,height,r_frame_rate,bit_rate,channels,duration:stream_side_data_list:stream_tags=rotate:format=duration,size,bit_rate',
'-of', 'json',
file,
];
const p = spawn('ffprobe', args, { windowsHide: true });
let out = '', err = '';
p.stdout.on('data', d => (out += d));
p.stderr.on('data', d => (err += d));
p.on('error', reject);
p.on('close', code => {
if (code !== 0) return reject(new Error(err.trim() || `ffprobe exited ${code}`));
try { resolve(summarize(JSON.parse(out))); }
catch (e) { reject(e); }
});
});
}
function parseFps(r) {
if (!r) return null;
const [a, b] = r.split('/').map(Number);
return b ? +(a / b).toFixed(3) : a;
}
function summarize(j) {
const v = (j.streams || []).find(s => s.codec_type === 'video');
const a = (j.streams || []).find(s => s.codec_type === 'audio');
const fmt = j.format || {};
const info = {
duration: +(fmt.duration || v?.duration || 0),
size: +(fmt.size || 0),
bitrate: +(fmt.bit_rate || 0),
video: v ? {
codec: v.codec_name,
width: v.width,
height: v.height,
fps: parseFps(v.r_frame_rate),
bitrate: +(v.bit_rate || 0),
} : null,
audio: a ? { codec: a.codec_name, channels: a.channels } : null,
projection: 'flat',
projectionSource: 'none',
stereo: 'mono',
};
const sph = (v?.side_data_list || []).find(s => s.side_data_type === 'Spherical Mapping');
if (sph) {
info.projection = sph.projection === 'equirectangular' ? 'equirectangular' : (sph.projection || 'unknown');
info.projectionSource = 'metadata';
info.sphericalYaw = sph.yaw || 0;
info.sphericalPitch = sph.pitch || 0;
info.sphericalRoll = sph.roll || 0;
} else if (v && v.width >= 2048 && Math.abs(v.width / v.height - 2) < 0.02) {
// No metadata, but a 2:1 frame at this size is almost certainly an equirect panorama.
info.projection = 'equirectangular';
info.projectionSource = 'aspect-guess';
}
const st = (v?.side_data_list || []).find(s => s.side_data_type === 'Stereo 3D');
if (st && st.type && st.type !== '2D') info.stereo = st.type;
return info;
}
/** Small persistent cache keyed on path + size + mtime so we don't re-probe on restart. */
export class ProbeCache {
constructor(file) {
this.file = file;
this.map = new Map();
this.loaded = false;
this.saving = null;
}
async load() {
try {
const j = JSON.parse(await fs.readFile(this.file, 'utf8'));
for (const [k, v] of Object.entries(j)) this.map.set(k, v);
} catch { /* first run */ }
this.loaded = true;
}
async get(file, stat) {
if (!this.loaded) await this.load();
const key = `${file}|${stat.size}|${Math.floor(stat.mtimeMs)}`;
if (this.map.has(key)) return this.map.get(key);
const info = await ffprobe(file);
this.map.set(key, info);
this.save();
return info;
}
save() {
// Debounce writes; a stale cache file only costs a re-probe.
if (this.saving) return;
this.saving = setTimeout(async () => {
this.saving = null;
try {
await fs.mkdir(path.dirname(this.file), { recursive: true });
await fs.writeFile(this.file, JSON.stringify(Object.fromEntries(this.map), null, 1));
} catch (e) { console.warn('probe cache save failed:', e.message); }
}, 500);
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Inject Google "Spherical Video V1" metadata into an MP4 so that any player
* (VLC, YouTube, this app) recognises the file as an equirectangular 360 video.
*
* ffmpeg drops the spherical side data when re-encoding, so we add the uuid
* box back ourselves: it lives at the end of the video `trak`, and because
* moov usually precedes mdat (faststart) every chunk offset after the
* insertion point has to be shifted by the box size.
*/
import fs from 'node:fs/promises';
const SPHERICAL_UUID = Buffer.from('ffcc8263f8554a938814587a02521fdd', 'hex');
const XML =
'<?xml version="1.0"?><rdf:SphericalVideo\n' +
'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"\n' +
'xmlns:GSpherical="http://ns.google.com/videos/1.0/spherical/">' +
'<GSpherical:Spherical>true</GSpherical:Spherical>' +
'<GSpherical:Stitched>true</GSpherical:Stitched>' +
'<GSpherical:StitchingSoftware>360 Player</GSpherical:StitchingSoftware>' +
'<GSpherical:ProjectionType>equirectangular</GSpherical:ProjectionType>' +
'</rdf:SphericalVideo>';
function makeUuidBox() {
const payload = Buffer.from(XML, 'utf8');
const box = Buffer.alloc(8 + 16 + payload.length);
box.writeUInt32BE(box.length, 0);
box.write('uuid', 4, 'latin1');
SPHERICAL_UUID.copy(box, 8);
payload.copy(box, 24);
return box;
}
/** Iterate the boxes inside buf[start, end). */
function* boxes(buf, start, end) {
let p = start;
while (p + 8 <= end) {
let size = buf.readUInt32BE(p);
const type = buf.toString('latin1', p + 4, p + 8);
let headerSize = 8;
if (size === 1) { size = Number(buf.readBigUInt64BE(p + 8)); headerSize = 16; }
else if (size === 0) size = end - p;
if (size < headerSize || p + size > end) throw new Error(`box ${type} @${p} 大小不正確`);
yield { pos: p, size, type, headerSize, end: p + size };
p += size;
}
}
const kids = (buf, b) => boxes(buf, b.pos + b.headerSize, b.end);
function child(buf, b, type) {
for (const c of kids(buf, b)) if (c.type === type) return c;
return null;
}
function descend(buf, b, ...types) {
for (const t of types) { b = b && child(buf, b, t); }
return b;
}
async function copyRange(src, dst, from, to) {
const chunk = Buffer.alloc(4 * 1024 * 1024);
let pos = from;
while (pos < to) {
const { bytesRead } = await src.read(chunk, 0, Math.min(chunk.length, to - pos), pos);
if (!bytesRead) throw new Error('讀取檔案時提前結束');
await dst.write(chunk, 0, bytesRead);
pos += bytesRead;
}
}
/**
* Write a copy of `file` with spherical metadata to `outFile`.
* Returns { injected: true } or { injected: false, reason } (outFile untouched).
*/
export async function injectSphericalV1(file, outFile) {
let fh = await fs.open(file, 'r');
try {
const { size: fileSize } = await fh.stat();
// Top-level scan for moov.
let moov = null;
const hdr = Buffer.alloc(16);
for (let pos = 0; pos + 8 <= fileSize;) {
const { bytesRead } = await fh.read(hdr, 0, 16, pos);
if (bytesRead < 8) break;
let size = hdr.readUInt32BE(0);
const type = hdr.toString('latin1', 4, 8);
let headerSize = 8;
if (size === 1) { size = Number(hdr.readBigUInt64BE(8)); headerSize = 16; }
else if (size === 0) size = fileSize - pos;
if (size < headerSize) break;
if (type === 'moov') { moov = { pos, size, headerSize }; break; }
pos += size;
}
if (!moov) return { injected: false, reason: '找不到 moov' };
if (moov.headerSize !== 8) return { injected: false, reason: 'moov 使用 64-bit size' };
if (moov.size > 512 * 1024 * 1024) return { injected: false, reason: 'moov 過大' };
const buf = Buffer.alloc(moov.size);
await fh.read(buf, 0, moov.size, moov.pos);
const moovBox = { pos: 0, size: moov.size, type: 'moov', headerSize: 8, end: moov.size };
const traks = [...kids(buf, moovBox)].filter(c => c.type === 'trak');
const videoTrak = traks.find(t => {
const hdlr = descend(buf, t, 'mdia', 'hdlr');
return hdlr && buf.toString('latin1', hdlr.pos + 16, hdlr.pos + 20) === 'vide';
});
if (!videoTrak) return { injected: false, reason: '找不到視訊 trak' };
if (videoTrak.headerSize !== 8) return { injected: false, reason: 'trak 使用 64-bit size' };
for (const c of kids(buf, videoTrak)) {
if (c.type === 'uuid' && buf.subarray(c.pos + 8, c.pos + 24).equals(SPHERICAL_UUID)) {
return { injected: false, reason: '已經有 spherical metadata' };
}
}
const uuid = makeUuidBox();
const delta = uuid.length;
const insertAt = moov.pos + videoTrak.end; // absolute file offset of the insertion
// Shift every chunk offset that points past the insertion point.
for (const t of traks) {
const stbl = descend(buf, t, 'mdia', 'minf', 'stbl');
if (!stbl) continue;
for (const c of kids(buf, stbl)) {
if (c.type === 'stco') {
const n = buf.readUInt32BE(c.pos + 12);
for (let i = 0; i < n; i++) {
const o = c.pos + 16 + i * 4;
const v = buf.readUInt32BE(o);
if (v >= insertAt) {
if (v + delta > 0xffffffff) return { injected: false, reason: 'stco 偏移量溢位' };
buf.writeUInt32BE(v + delta, o);
}
}
} else if (c.type === 'co64') {
const n = buf.readUInt32BE(c.pos + 12);
const at = BigInt(insertAt), d = BigInt(delta);
for (let i = 0; i < n; i++) {
const o = c.pos + 16 + i * 8;
const v = buf.readBigUInt64BE(o);
if (v >= at) buf.writeBigUInt64BE(v + d, o);
}
}
}
}
buf.writeUInt32BE(moov.size + delta, 0);
buf.writeUInt32BE(videoTrak.size + delta, videoTrak.pos);
const newMoov = Buffer.concat([buf.subarray(0, videoTrak.end), uuid, buf.subarray(videoTrak.end)]);
const out = await fs.open(outFile, 'w');
try {
await copyRange(fh, out, 0, moov.pos);
await out.write(newMoov);
await copyRange(fh, out, moov.pos + moov.size, fileSize);
} finally {
await out.close();
}
return { injected: true };
} finally {
await fh.close();
}
}
+387
View File
@@ -0,0 +1,387 @@
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;
}