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