mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: server-side preview generation for non-native video/audio formats
This commit is contained in:
@@ -47,6 +47,7 @@ import { docsRoutes } from "./routes/docs.js";
|
|||||||
import { registerEnterpriseRoutes } from "./routes/enterprise/index.js";
|
import { registerEnterpriseRoutes } from "./routes/enterprise/index.js";
|
||||||
import { registerFeatureRoutes } from "./routes/features.js";
|
import { registerFeatureRoutes } from "./routes/features.js";
|
||||||
import { registerFetchUrlsRoute } from "./routes/fetch-urls.js";
|
import { registerFetchUrlsRoute } from "./routes/fetch-urls.js";
|
||||||
|
import { filePreviewRoutes } from "./routes/file-preview.js";
|
||||||
import { fileRoutes } from "./routes/files.js";
|
import { fileRoutes } from "./routes/files.js";
|
||||||
import { registerMemeTemplates } from "./routes/meme-templates.js";
|
import { registerMemeTemplates } from "./routes/meme-templates.js";
|
||||||
import { registerPipelineRoutes } from "./routes/pipeline.js";
|
import { registerPipelineRoutes } from "./routes/pipeline.js";
|
||||||
@@ -348,6 +349,9 @@ await fileRoutes(app);
|
|||||||
// User file library routes (persistent file management with versioning)
|
// User file library routes (persistent file management with versioning)
|
||||||
await userFileRoutes(app);
|
await userFileRoutes(app);
|
||||||
|
|
||||||
|
// File preview routes (server-side video/audio preview generation)
|
||||||
|
await filePreviewRoutes(app);
|
||||||
|
|
||||||
// Meme template listing and static serving (before tool routes which have catch-all)
|
// Meme template listing and static serving (before tool routes which have catch-all)
|
||||||
await registerMemeTemplates(app);
|
await registerMemeTemplates(app);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/v1/files/:id/preview
|
||||||
|
*
|
||||||
|
* Server-side preview generation for non-native video/audio formats.
|
||||||
|
* Generates a browser-playable H.264 MP4 (video) or MP3 (audio) preview
|
||||||
|
* and caches it on disk for subsequent requests.
|
||||||
|
*/
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { access, mkdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { runFfmpeg } from "@snapotter/media-engine";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const PREVIEW_DIR = ".previews";
|
||||||
|
let previewDirReady = false;
|
||||||
|
|
||||||
|
function previewDirPath(): string {
|
||||||
|
return join(env.FILES_STORAGE_PATH, PREVIEW_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePreviewDir(): Promise<void> {
|
||||||
|
if (previewDirReady) return;
|
||||||
|
await mkdir(previewDirPath(), { recursive: true });
|
||||||
|
previewDirReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewPath(fileId: string, ext: string): string {
|
||||||
|
return join(previewDirPath(), `${fileId}${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function filePreviewRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get(
|
||||||
|
"/api/v1/files/:id/preview",
|
||||||
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||||
|
const user = requireAuth(request, reply);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
|
||||||
|
|
||||||
|
if (
|
||||||
|
!file ||
|
||||||
|
(file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))
|
||||||
|
) {
|
||||||
|
return reply.status(404).send({ error: "File not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideo = file.mimeType.startsWith("video/");
|
||||||
|
const isAudio = file.mimeType.startsWith("audio/");
|
||||||
|
|
||||||
|
if (!isVideo && !isAudio) {
|
||||||
|
return reply
|
||||||
|
.status(400)
|
||||||
|
.send({ error: "Preview only supported for video and audio files" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewExt = isVideo ? ".mp4" : ".mp3";
|
||||||
|
const contentType = isVideo ? "video/mp4" : "audio/mpeg";
|
||||||
|
const cachedPath = previewPath(id, previewExt);
|
||||||
|
|
||||||
|
// Serve from cache if available
|
||||||
|
if (await fileExists(cachedPath)) {
|
||||||
|
return reply
|
||||||
|
.header("Content-Type", contentType)
|
||||||
|
.header("Cache-Control", "public, max-age=86400, immutable")
|
||||||
|
.send(createReadStream(cachedPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate preview via FFmpeg
|
||||||
|
await ensurePreviewDir();
|
||||||
|
const inputPath = getStoredFilePath(file.storedName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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",
|
||||||
|
cachedPath,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await runFfmpeg([
|
||||||
|
"-i",
|
||||||
|
inputPath,
|
||||||
|
"-t",
|
||||||
|
"60",
|
||||||
|
"-c:a",
|
||||||
|
"libmp3lame",
|
||||||
|
"-b:a",
|
||||||
|
"128k",
|
||||||
|
"-y",
|
||||||
|
cachedPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, fileId: id }, "Preview generation failed");
|
||||||
|
return reply.status(422).send({ error: "Could not generate preview" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply
|
||||||
|
.header("Content-Type", contentType)
|
||||||
|
.header("Cache-Control", "public, max-age=86400, immutable")
|
||||||
|
.send(createReadStream(cachedPath));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.log.info("File preview routes registered");
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { TOOLS } from "@snapotter/shared";
|
import { TOOLS } from "@snapotter/shared";
|
||||||
import { FileImage, FileText, ImageIcon, Music, Video } from "lucide-react";
|
import { FileImage, FileText, ImageIcon, Loader2, Music, Play, Video } from "lucide-react";
|
||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import {
|
import {
|
||||||
apiGetFileDetails,
|
apiGetFileDetails,
|
||||||
formatHeaders,
|
formatHeaders,
|
||||||
getFileDownloadUrl,
|
getFileDownloadUrl,
|
||||||
|
getFilePreviewUrl,
|
||||||
getFileThumbnailUrl,
|
getFileThumbnailUrl,
|
||||||
type UserFileDetail,
|
type UserFileDetail,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@@ -64,6 +65,16 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
|
|||||||
return <img src={blobUrl} alt={alt} className={className} />;
|
return <img src={blobUrl} alt={alt} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NATIVE_VIDEO = new Set(["mp4", "webm", "ogg", "ogv", "m4v"]);
|
||||||
|
const NATIVE_AUDIO = new Set(["mp3", "wav", "ogg", "oga", "opus", "aac", "m4a", "flac", "webm"]);
|
||||||
|
|
||||||
|
function isNativePlayable(mimeType: string, filename: string): boolean {
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (mimeType.startsWith("video/")) return NATIVE_VIDEO.has(ext);
|
||||||
|
if (mimeType.startsWith("audio/")) return NATIVE_AUDIO.has(ext);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function FilePreview({
|
function FilePreview({
|
||||||
fileId,
|
fileId,
|
||||||
mimeType,
|
mimeType,
|
||||||
@@ -73,14 +84,29 @@ function FilePreview({
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
name: string;
|
name: string;
|
||||||
}) {
|
}) {
|
||||||
const downloadUrl = getFileDownloadUrl(fileId);
|
|
||||||
const headers = formatHeaders();
|
|
||||||
const [mediaSrc, setMediaSrc] = useState<string | null>(null);
|
const [mediaSrc, setMediaSrc] = useState<string | null>(null);
|
||||||
|
const [previewSrc, setPreviewSrc] = useState<string | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewError, setPreviewError] = useState(false);
|
||||||
|
|
||||||
|
const isMedia = mimeType.startsWith("video/") || mimeType.startsWith("audio/");
|
||||||
|
const nativePlayable = isMedia && isNativePlayable(mimeType, name);
|
||||||
|
|
||||||
|
// Fetch native-playable media directly from the download endpoint.
|
||||||
|
// Also resets server-side preview state when the file changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mimeType.startsWith("video/") && !mimeType.startsWith("audio/")) return;
|
// Reset server-side preview state on every file change
|
||||||
|
setPreviewSrc((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setPreviewLoading(false);
|
||||||
|
setPreviewError(false);
|
||||||
|
|
||||||
|
if (!isMedia || !nativePlayable) return;
|
||||||
let revoked = false;
|
let revoked = false;
|
||||||
fetch(downloadUrl, { headers })
|
const url = getFileDownloadUrl(fileId);
|
||||||
|
fetch(url, { headers: formatHeaders() })
|
||||||
.then((res) => res.blob())
|
.then((res) => res.blob())
|
||||||
.then((blob) => {
|
.then((blob) => {
|
||||||
if (!revoked) setMediaSrc(URL.createObjectURL(blob));
|
if (!revoked) setMediaSrc(URL.createObjectURL(blob));
|
||||||
@@ -88,30 +114,132 @@ function FilePreview({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
revoked = true;
|
revoked = true;
|
||||||
if (mediaSrc) URL.revokeObjectURL(mediaSrc);
|
setMediaSrc((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
}, [isMedia, nativePlayable, fileId]);
|
||||||
|
|
||||||
|
const handleGeneratePreview = useCallback(() => {
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setPreviewError(false);
|
||||||
|
fetch(getFilePreviewUrl(fileId), { headers: formatHeaders() })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Preview generation failed");
|
||||||
|
return res.blob();
|
||||||
|
})
|
||||||
|
.then((blob) => {
|
||||||
|
setPreviewSrc(URL.createObjectURL(blob));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setPreviewError(true);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
});
|
||||||
}, [fileId]);
|
}, [fileId]);
|
||||||
|
|
||||||
|
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
|
||||||
|
// Video files
|
||||||
if (mimeType.startsWith("video/")) {
|
if (mimeType.startsWith("video/")) {
|
||||||
return mediaSrc ? (
|
// Native-playable: fetch and render directly
|
||||||
<video src={mediaSrc} controls className="w-full rounded-lg max-h-48 bg-black">
|
if (nativePlayable) {
|
||||||
<track kind="captions" />
|
return mediaSrc ? (
|
||||||
</video>
|
<video src={mediaSrc} controls className="w-full rounded-lg max-h-48 bg-black">
|
||||||
) : (
|
<track kind="captions" />
|
||||||
<div className="w-full h-32 rounded-lg bg-muted flex items-center justify-center">
|
</video>
|
||||||
<Video className="h-8 w-8 text-muted-foreground animate-pulse" />
|
) : (
|
||||||
|
<div className="w-full h-32 rounded-lg bg-muted flex items-center justify-center">
|
||||||
|
<Video className="h-8 w-8 text-muted-foreground animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-native: show preview button or server-generated preview
|
||||||
|
if (previewSrc) {
|
||||||
|
return (
|
||||||
|
<video src={previewSrc} controls className="w-full rounded-lg max-h-48 bg-black">
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-32 rounded-lg bg-muted flex items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-32 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGeneratePreview}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Play className="h-5 w-5" />
|
||||||
|
Generate Preview
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{previewError
|
||||||
|
? "Preview generation failed. Try again."
|
||||||
|
: `${ext.toUpperCase()} requires server-side conversion`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio files
|
||||||
if (mimeType.startsWith("audio/")) {
|
if (mimeType.startsWith("audio/")) {
|
||||||
return mediaSrc ? (
|
// Native-playable: fetch and render directly
|
||||||
<Suspense fallback={<div className="w-full h-24 rounded-lg bg-muted animate-pulse" />}>
|
if (nativePlayable) {
|
||||||
<WaveformPlayer src={mediaSrc} className="w-full" />
|
return mediaSrc ? (
|
||||||
</Suspense>
|
<Suspense fallback={<div className="w-full h-24 rounded-lg bg-muted animate-pulse" />}>
|
||||||
) : (
|
<WaveformPlayer src={mediaSrc} className="w-full" />
|
||||||
<div className="w-full h-24 rounded-lg bg-muted flex items-center justify-center">
|
</Suspense>
|
||||||
<Music className="h-8 w-8 text-muted-foreground animate-pulse" />
|
) : (
|
||||||
|
<div className="w-full h-24 rounded-lg bg-muted flex items-center justify-center">
|
||||||
|
<Music className="h-8 w-8 text-muted-foreground animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-native: show preview button or server-generated preview
|
||||||
|
if (previewSrc) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="w-full h-24 rounded-lg bg-muted animate-pulse" />}>
|
||||||
|
<WaveformPlayer src={previewSrc} className="w-full" />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-32 rounded-lg bg-muted flex items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-32 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGeneratePreview}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Play className="h-5 w-5" />
|
||||||
|
Generate Preview
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{previewError
|
||||||
|
? "Preview generation failed. Try again."
|
||||||
|
: `${ext.toUpperCase()} requires server-side conversion`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,10 @@ export function getFileDownloadUrl(id: string): string {
|
|||||||
return `/api/v1/files/${id}/download`;
|
return `/api/v1/files/${id}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFilePreviewUrl(id: string): string {
|
||||||
|
return `/api/v1/files/${id}/preview`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user