122 lines
4.4 KiB
JavaScript
122 lines
4.4 KiB
JavaScript
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';
|