feat: on-demand preview generation with progress messages for non-native formats

This commit is contained in:
SnapOtter
2026-06-15 12:04:30 +08:00
parent 511e941da0
commit d3cd4e41b5
24 changed files with 407 additions and 17 deletions
+141 -2
View File
@@ -5,8 +5,10 @@
* and document files. Generates browser-playable H.264 MP4 (video),
* MP3 (audio), or PDF (documents) previews and caches them on disk.
*/
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { access, copyFile, mkdir, rename, rm } from "node:fs/promises";
import { access, copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { convertDocument, sofficeAvailable } from "@snapotter/doc-engine";
import { runFfmpeg } from "@snapotter/media-engine";
@@ -16,7 +18,7 @@ import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { getStoredFilePath } from "../lib/file-storage.js";
import { hasEffectivePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js";
const PREVIEW_DIR = ".previews";
let previewDirReady = false;
@@ -207,5 +209,142 @@ export async function filePreviewRoutes(app: FastifyInstance): Promise<void> {
},
);
// ── On-demand preview for uploaded (non-stored) media files ─────
app.post("/api/v1/preview/generate", async (request: FastifyRequest, reply: FastifyReply) => {
// Optional auth -- the preview is for the user's own uploaded file
getAuthUser(request);
const parts = request.parts();
let fileBuffer: Buffer | null = null;
let filename = "input";
for await (const part of parts) {
if (part.type !== "file") continue;
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = part.filename ?? "input";
break; // only process the first file
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No file provided" });
}
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const videoExts = new Set([
"avi",
"mkv",
"wmv",
"flv",
"mov",
"mpg",
"mpeg",
"m4v",
"3gp",
"3g2",
"ts",
"mts",
"m2ts",
"vob",
"divx",
"asf",
"rm",
"rmvb",
"f4v",
"ogv",
"mp4",
"webm",
"ogg",
]);
const audioExts = new Set([
"wav",
"flac",
"aac",
"wma",
"ogg",
"oga",
"opus",
"m4a",
"aiff",
"aif",
"amr",
"ape",
"ac3",
"dts",
"mp3",
]);
const isVideo = videoExts.has(ext);
const isAudio = audioExts.has(ext);
if (!isVideo && !isAudio) {
return reply.status(400).send({ error: "Unsupported file type for preview" });
}
const id = randomUUID();
const inputPath = join(tmpdir(), `snapotter-preview-${id}.${ext}`);
const outputExt = isVideo ? "mp4" : "mp3";
const outputPath = join(tmpdir(), `snapotter-preview-${id}-out.${outputExt}`);
try {
await writeFile(inputPath, fileBuffer);
if (isVideo) {
await runFfmpeg([
"-i",
inputPath,
"-t",
"30",
"-vf",
"scale='min(720,iw)':-2",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"28",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-y",
outputPath,
]);
} else {
await runFfmpeg([
"-i",
inputPath,
"-t",
"60",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
"-y",
outputPath,
]);
}
const outputBuffer = await readFile(outputPath);
const contentType = isVideo ? "video/mp4" : "audio/mpeg";
return reply
.header("Content-Type", contentType)
.header("Content-Length", outputBuffer.length)
.send(outputBuffer);
} catch (err) {
request.log.error({ err, filename }, "On-demand preview generation failed");
return reply.status(422).send({ error: "Could not generate preview" });
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
});
app.log.info("File preview routes registered");
}