mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: on-demand preview generation with progress messages for non-native formats
This commit is contained in:
@@ -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} · {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} · {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} · {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;
|
||||
}
|
||||
@@ -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()} ·{" "}
|
||||
{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">
|
||||
|
||||
Reference in New Issue
Block a user