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';