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