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),
|
* and document files. Generates browser-playable H.264 MP4 (video),
|
||||||
* MP3 (audio), or PDF (documents) previews and caches them on disk.
|
* MP3 (audio), or PDF (documents) previews and caches them on disk.
|
||||||
*/
|
*/
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { createReadStream } from "node:fs";
|
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 { join } from "node:path";
|
||||||
import { convertDocument, sofficeAvailable } from "@snapotter/doc-engine";
|
import { convertDocument, sofficeAvailable } from "@snapotter/doc-engine";
|
||||||
import { runFfmpeg } from "@snapotter/media-engine";
|
import { runFfmpeg } from "@snapotter/media-engine";
|
||||||
@@ -16,7 +18,7 @@ import { env } from "../config.js";
|
|||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { getStoredFilePath } from "../lib/file-storage.js";
|
import { getStoredFilePath } from "../lib/file-storage.js";
|
||||||
import { hasEffectivePermission } from "../permissions.js";
|
import { hasEffectivePermission } from "../permissions.js";
|
||||||
import { requireAuth } from "../plugins/auth.js";
|
import { getAuthUser, requireAuth } from "../plugins/auth.js";
|
||||||
|
|
||||||
const PREVIEW_DIR = ".previews";
|
const PREVIEW_DIR = ".previews";
|
||||||
let previewDirReady = false;
|
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");
|
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,
|
FileImage,
|
||||||
Loader2,
|
Loader2,
|
||||||
Upload,
|
Upload,
|
||||||
Video,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -61,6 +60,11 @@ const WaveformPlayer = lazy(() =>
|
|||||||
const DocumentView = lazy(() =>
|
const DocumentView = lazy(() =>
|
||||||
import("@/components/tools/document-view").then((m) => ({ default: m.DocumentView })),
|
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. */
|
/** Formats that browsers can render in <img> tags. */
|
||||||
const BROWSER_PREVIEWABLE_EXTS = new Set([
|
const BROWSER_PREVIEWABLE_EXTS = new Set([
|
||||||
@@ -668,21 +672,16 @@ export function ToolPage() {
|
|||||||
const nativeVideoExts = new Set(["mp4", "webm", "ogg", "ogv", "m4v", "mov"]);
|
const nativeVideoExts = new Set(["mp4", "webm", "ogg", "ogv", "m4v", "mov"]);
|
||||||
const isNativeVideo = nativeVideoExts.has(currentExt);
|
const isNativeVideo = nativeVideoExts.has(currentExt);
|
||||||
|
|
||||||
if (!isNativeVideo && currentExt) {
|
if (!isNativeVideo && currentExt && currentEntry?.file) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||||
<div className="text-center p-8 max-w-xs">
|
<NonNativePreview
|
||||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
|
file={currentEntry.file}
|
||||||
<Video className="h-8 w-8 text-muted-foreground" />
|
filename={currentFileName}
|
||||||
</div>
|
fileSize={currentEntry.file.size}
|
||||||
<p className="font-medium text-foreground mb-1">{currentFileName}</p>
|
modality="video"
|
||||||
<p className="text-sm text-muted-foreground mb-1">
|
/>
|
||||||
{currentExt.toUpperCase()} ·{" "}
|
</Suspense>
|
||||||
{currentEntry?.file ? formatFileSize(currentEntry.file.size) : ""}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground/60">{t.toolPage.previewUnavailable}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -901,6 +900,20 @@ export function ToolPage() {
|
|||||||
const fsize = selectedFileSize ?? files[0].size;
|
const fsize = selectedFileSize ?? files[0].size;
|
||||||
if (!canBrowserPreview(originalBlobUrl, fname)) {
|
if (!canBrowserPreview(originalBlobUrl, fname)) {
|
||||||
const ext = fname.split(".").pop()?.toUpperCase() ?? "";
|
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 (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center p-8 max-w-xs">
|
<div className="text-center p-8 max-w-xs">
|
||||||
|
|||||||
@@ -2454,6 +2454,8 @@ export const ar: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2468,6 +2468,8 @@ export const de: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2418,6 +2418,8 @@ export const en = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2450,6 +2450,8 @@ export const es: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2469,6 +2469,8 @@ export const fr: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2451,6 +2451,8 @@ export const hi: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2463,6 +2463,8 @@ export const id: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2462,6 +2462,8 @@ export const it: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2421,6 +2421,8 @@ export const ja: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2406,6 +2406,8 @@ export const ko: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2465,6 +2465,8 @@ export const nl: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2466,6 +2466,8 @@ export const pl: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2462,6 +2462,8 @@ export const ptBR: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2464,6 +2464,8 @@ export const ru: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2461,6 +2461,8 @@ export const sv: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2443,6 +2443,8 @@ export const th: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2466,6 +2466,8 @@ export const tr: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2464,6 +2464,8 @@ export const uk: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2463,6 +2463,8 @@ export const vi: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2396,6 +2396,8 @@ export const zhCN: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
@@ -2394,6 +2394,8 @@ export const zhTW: TranslationKeys = {
|
|||||||
startOver: "Start over with a new file",
|
startOver: "Start over with a new file",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
previewUnavailable: "Preview will be available after processing",
|
previewUnavailable: "Preview will be available after processing",
|
||||||
|
generatePreview: "Generate Preview",
|
||||||
|
previewFailed: "Preview generation failed",
|
||||||
backToTools: "Back to Tools",
|
backToTools: "Back to Tools",
|
||||||
original: "Original",
|
original: "Original",
|
||||||
processed: "Processed",
|
processed: "Processed",
|
||||||
|
|||||||
Reference in New Issue
Block a user