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");
}
@@ -0,0 +1,196 @@
import { Play, RefreshCw, Video, Volume2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatFileSize } from "@/lib/download";
import { cn } from "@/lib/utils";
const PROGRESS_MESSAGES = [
"Warming up the otter...",
"Crunching pixels...",
"Teaching the codec...",
"Almost there...",
"Brewing the preview...",
"Convincing the frames...",
"Polishing the output...",
"Just a moment...",
];
type PreviewState = "idle" | "generating" | "ready" | "error";
export interface NonNativePreviewProps {
file: File;
filename: string;
fileSize: number;
modality: "video" | "audio";
}
export function NonNativePreview({ file, filename, fileSize, modality }: NonNativePreviewProps) {
const { t } = useTranslation();
const [state, setState] = useState<PreviewState>("idle");
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [messageIndex, setMessageIndex] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Clean up blob URL on unmount
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
if (intervalRef.current) clearInterval(intervalRef.current);
abortRef.current?.abort();
};
}, [previewUrl]);
const startMessageRotation = useCallback(() => {
setMessageIndex(0);
intervalRef.current = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROGRESS_MESSAGES.length);
}, 2500);
}, []);
const stopMessageRotation = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
const generatePreview = useCallback(async () => {
setState("generating");
startMessageRotation();
const controller = new AbortController();
abortRef.current = controller;
try {
const formData = new FormData();
formData.append("file", file, filename);
const response = await fetch("/api/v1/preview/generate", {
method: "POST",
body: formData,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Preview generation failed: ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Revoke previous URL if any
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(url);
setState("ready");
} catch (err) {
if ((err as Error).name !== "AbortError") {
setState("error");
}
} finally {
stopMessageRotation();
}
}, [file, filename, previewUrl, startMessageRotation, stopMessageRotation]);
const ext = filename.split(".").pop()?.toUpperCase() ?? "";
const IconComponent = modality === "audio" ? Volume2 : Video;
// Idle state: file info + generate button
if (state === "idle") {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<IconComponent className="h-8 w-8 text-muted-foreground" />
</div>
<p className="font-medium text-foreground mb-1">{filename}</p>
<p className="text-sm text-muted-foreground mb-3">
{ext} &middot; {formatFileSize(fileSize)}
</p>
<button
type="button"
onClick={generatePreview}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
>
<Play className="h-4 w-4" />
{t.toolPage.generatePreview}
</button>
</div>
</div>
);
}
// Generating state: progress bar + rotating messages
if (state === "generating") {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs w-full">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<IconComponent className="h-8 w-8 text-muted-foreground" />
</div>
<p className="font-medium text-foreground mb-1">{filename}</p>
<p className="text-sm text-muted-foreground mb-4">
{ext} &middot; {formatFileSize(fileSize)}
</p>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden mb-3">
<div
className={cn(
"h-full w-1/4 bg-primary rounded-full",
"animate-[shimmer_1.5s_ease-in-out_infinite]",
)}
/>
</div>
<p className="text-sm text-muted-foreground">{PROGRESS_MESSAGES[messageIndex]}</p>
</div>
</div>
);
}
// Error state: retry button
if (state === "error") {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<IconComponent className="h-8 w-8 text-muted-foreground" />
</div>
<p className="font-medium text-foreground mb-1">{t.toolPage.previewFailed}</p>
<p className="text-sm text-muted-foreground mb-3">
{filename} &middot; {formatFileSize(fileSize)}
</p>
<button
type="button"
onClick={generatePreview}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
>
<RefreshCw className="h-4 w-4" />
{t.common.retry}
</button>
</div>
</div>
);
}
// Ready state: show the player
if (state === "ready" && previewUrl) {
if (modality === "audio") {
return (
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
{/* biome-ignore lint/a11y/useMediaCaption: preview audio player */}
<audio controls className="w-full" src={previewUrl} />
</div>
</div>
);
}
return (
<div className="flex-1 flex items-center justify-center p-2">
{/* biome-ignore lint/a11y/useMediaCaption: preview video player */}
<video controls className="max-h-full max-w-full rounded-md" src={previewUrl} />
</div>
);
}
return null;
}
+28 -15
View File
@@ -10,7 +10,6 @@ import {
FileImage,
Loader2,
Upload,
Video,
XCircle,
} from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -61,6 +60,11 @@ const WaveformPlayer = lazy(() =>
const DocumentView = lazy(() =>
import("@/components/tools/document-view").then((m) => ({ default: m.DocumentView })),
);
const NonNativePreview = lazy(() =>
import("@/components/common/non-native-preview").then((m) => ({
default: m.NonNativePreview,
})),
);
/** Formats that browsers can render in <img> tags. */
const BROWSER_PREVIEWABLE_EXTS = new Set([
@@ -668,21 +672,16 @@ export function ToolPage() {
const nativeVideoExts = new Set(["mp4", "webm", "ogg", "ogv", "m4v", "mov"]);
const isNativeVideo = nativeVideoExts.has(currentExt);
if (!isNativeVideo && currentExt) {
if (!isNativeVideo && currentExt && currentEntry?.file) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<Video className="h-8 w-8 text-muted-foreground" />
</div>
<p className="font-medium text-foreground mb-1">{currentFileName}</p>
<p className="text-sm text-muted-foreground mb-1">
{currentExt.toUpperCase()} &middot;{" "}
{currentEntry?.file ? formatFileSize(currentEntry.file.size) : ""}
</p>
<p className="text-xs text-muted-foreground/60">{t.toolPage.previewUnavailable}</p>
</div>
</div>
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
<NonNativePreview
file={currentEntry.file}
filename={currentFileName}
fileSize={currentEntry.file.size}
modality="video"
/>
</Suspense>
);
}
return (
@@ -901,6 +900,20 @@ export function ToolPage() {
const fsize = selectedFileSize ?? files[0].size;
if (!canBrowserPreview(originalBlobUrl, fname)) {
const ext = fname.split(".").pop()?.toUpperCase() ?? "";
const previewModality =
tool?.modality === "video" ? "video" : tool?.modality === "audio" ? "audio" : null;
if (previewModality && currentEntry?.file) {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
<NonNativePreview
file={currentEntry.file}
filename={fname}
fileSize={fsize}
modality={previewModality}
/>
</Suspense>
);
}
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8 max-w-xs">