Files
360_player/lib/probe.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

107 lines
3.6 KiB
JavaScript

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);
}
}