Files

122 lines
4.4 KiB
JavaScript
Raw Permalink Normal View History

2026-09-13 20:23:05 +01:00
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { SOFFICE_BIN, FFMPEG_BIN, CONVERT_TIMEOUT_MS } from './config.js';
// Anything LibreOffice can turn into a PDF becomes a paged "deck".
const DOC_EXT = new Set([
'ppt', 'pptx', 'odp', 'ppsx', 'pps',
'doc', 'docx', 'odt', 'rtf', 'txt', 'md',
'xls', 'xlsx', 'ods', 'csv', 'tsv'
]);
const VIDEO_EXT = new Set([
'mp4', 'webm', 'ogg', 'ogv', 'mov', 'm4v',
'avi', 'mkv', 'wmv', 'flv', '3gp', 'mpeg', 'mpg', 'ts'
]);
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']);
// Formats browsers can generally stream without a re-encode.
const WEB_VIDEO = new Set(['mp4', 'webm', 'ogg', 'ogv']);
function run(cmd, args, { timeout = CONVERT_TIMEOUT_MS, cwd, env } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env } });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`${cmd} timed out after ${timeout}ms`));
}, timeout);
child.stdout.on('data', (d) => (stdout += d.toString()));
child.stderr.on('data', (d) => (stderr += d.toString()));
child.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`Could not start ${cmd}: ${err.message}`));
});
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve({ stdout, stderr });
else reject(new Error(`${cmd} exited with code ${code}: ${(stderr || stdout).slice(-500)}`));
});
});
}
async function convertToPdf(inputPath, outDir) {
// A throwaway per-job profile avoids the shared-lock problems LibreOffice
// hits when several conversions run at once.
const profileDir = path.join(outDir, '.lo-profile');
await run(SOFFICE_BIN, [
'--headless', '--norestore', '--nologo', '--nolockcheck', '--nodefault',
`-env:UserInstallation=file://${profileDir}`,
'--convert-to', 'pdf',
'--outdir', outDir,
inputPath
]);
const base = path.basename(inputPath, path.extname(inputPath));
const produced = path.join(outDir, `${base}.pdf`);
await fs.access(produced); // throws if LibreOffice silently failed
await fs.rm(profileDir, { recursive: true, force: true });
return produced;
}
async function toMp4(inputPath, outDir) {
const out = path.join(outDir, 'video.mp4');
try {
// Fast path: just repackage the existing streams into an mp4 container.
await run(FFMPEG_BIN, ['-y', '-i', inputPath, '-c', 'copy', '-movflags', '+faststart', out]);
return out;
} catch {
// Fall back to a real transcode for exotic codecs.
await run(FFMPEG_BIN, [
'-y', '-i', inputPath,
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
out
]);
return out;
}
}
/**
* Turn an uploaded file into something a browser can display.
* Returns { kind: 'pdf'|'video'|'image', viewerFile }.
*/
export async function processUpload({ inputPath, originalName, outDir }) {
const ext = path.extname(originalName).toLowerCase().replace('.', '');
await fs.mkdir(outDir, { recursive: true });
if (ext === 'pdf') {
const dest = path.join(outDir, 'document.pdf');
await fs.copyFile(inputPath, dest);
return { kind: 'pdf', viewerFile: 'document.pdf' };
}
if (DOC_EXT.has(ext)) {
const pdf = await convertToPdf(inputPath, outDir);
const dest = path.join(outDir, 'document.pdf');
if (pdf !== dest) await fs.rename(pdf, dest);
return { kind: 'pdf', viewerFile: 'document.pdf' };
}
if (VIDEO_EXT.has(ext)) {
if (WEB_VIDEO.has(ext)) {
const outExt = ext === 'ogv' ? 'ogg' : ext;
const dest = path.join(outDir, `video.${outExt}`);
await fs.copyFile(inputPath, dest);
return { kind: 'video', viewerFile: path.basename(dest) };
}
const mp4 = await toMp4(inputPath, outDir);
return { kind: 'video', viewerFile: path.basename(mp4) };
}
if (IMAGE_EXT.has(ext)) {
const dest = path.join(outDir, `image.${ext}`);
await fs.copyFile(inputPath, dest);
return { kind: 'image', viewerFile: path.basename(dest) };
}
throw new Error(`Unsupported file type: .${ext}. Upload a video, PDF, image, or an Office/OpenDocument file.`);
}
export const SUPPORTED_HINT = 'PowerPoint, Word, Excel, PDF, images, and common video formats';