v_1.7
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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';
|
||||
Reference in New Issue
Block a user