摘要: 將本機開發的 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>
107 lines
3.6 KiB
JavaScript
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);
|
|
}
|
|
}
|